- Example
- Run now
- Use
- test, one-off
Start here · 10 minutes
Everything in one page. Read this step. Open the other steps only for the topic you care about.
Outcome
A Fimo agent is now a chat that you keep. It has a starting prompt and routines. A routine is a task that runs without a person: by hand, on a schedule, and now when something happens. The event can come from inside the project (a publish, a content change, a form) or, next, from another website.
You never write a rule. You or the agent say it in plain words: "after each publish", "when a French blog post changes". Fimo turns the words into a small JSON rule. Code checks most of the rule for free. A very small model (Jev) answers a yes/no question only when code cannot decide.
We use this engine to build the six agents that the marketing site promises: SEO, GEO, Content, Performance, Accessibility and Compliance. Version 1 will not be good. The plan is to ship, look at real runs and iterate fast: mostly on how agents ask questions, and on moving users off the old Agents tab.
The model
An agent is a chat with routines
There is only one agent underneath. A named chat adds a lens: a starting prompt and its routines. Each routine runs in its own hidden thread.
The agent chat hears about a run only when it is worth your attention. A quiet run folds into one gray line.
flowchart LR W[Wake-up] --> R[Routine run
own thread] R --> Q{Worth telling?} Q -->|no| S[One gray line] Q -->|yes| C[Agent chat:
message or question]
One routine run, from wake-up to the agent chat.
Wake-ups
Three ways to wake up. The third one is new
By hand and schedule are on develop. Event is the smart-trigger work. It is on a branch with 27 commits and is not merged yet.
- Example
- Every Monday 08:00
- Floor
- 15 min between runs
- Example
- "After each publish"
- Kinds
- 13 internal events
Plain words
You say it in words. Fimo stores a rule and explains it back
The agent calls its routines tool with trigger: "...". One model call compiles the words into a rule, or refuses with a reason. The explanation that people read is built from the rule by code, so it cannot drift from what runs.
event_trigger (stored)The simplest rule: after each publish{
"text": "After each publish", // what the person or agent wrote
"explanation": "Runs after each publish of the site.", // built from the rule by code
"rule": {
"version": 1,
"when": [ { "on": "site.published" } ],
"limit": { "runsPerDay": 24 }
}
}Internal events
13 project events can start a routine
All of them already exist on our event bus. Rules use product names, so a bus rename does not break saved rules.
Site
Data
Code + chains
Cost
Code filters first. A small model only when a question needs it
"When the price changes" is a code check: changedFields includes price. It is free. "When a post mentions a competitor" needs reading, so Jev answers yes or no. If Jev is unsure, the routine runs and is told to check first: a missed change is worse than an extra run.
flowchart LR
E[Event] --> F[Code filters]
F -->|no match| X[Nothing]
F -->|match| J{Question?}
J -->|no| RUN[Run]
J -->|yes| JEV[Jev yes/no]
JEV -->|≥ 0.7| RUN
JEV -->|0.35–0.7| U[Run, marked unsure]
JEV -->|< 0.35| X
Thresholds from rule.ts on the triggers branch.
External websites
Next: watch other websites with the same engine
Competitor pages are not in our event bus. The watcher fetches them, compares only the part you care about, and sends external.changed with values before and after. After that event, it is the same rule, Jev and run path.
What we build
Six specialist agents, 28 routines, from the marketing site
The marketing site already lists them. Each routine has a pace: daily, weekly, monthly or on publish. "On publish" is exactly an event trigger.
- Routines
- 10
- Example
- Meta tags · on publish
- Routines
- 4
- Example
- Structured data · on publish
- Routines
- 5
- Example
- Competitor monitoring · daily
- Routines
- 3
- Example
- Image compression · on publish
- Routines
- 3
- Example
- Contrast · on publish
- Routines
- 3
- Example
- Brand claims · on publish
Iterate
Version 1 will be rough. We iterate on questions and on the move off the old tab
Agents ask questions with a form in the chat today. With many routines, questions pile up. We have three grouping ideas in mockups (group, triage, one at a time) and a planned Needs you list.
The old Agents tab (config-file agents) goes away. 88 real agents in 31 organizations move to agent chats with routines, with a reviewed migration.
Where we are
On develop, on a branch, or only designed
Status on 12 Oct, from code on origin/develop and the triggers branch, and from Linear. Green means it is on develop today. Amber means the code exists on a branch. Gray means designed only: a mockup, a plan or a Linear issue.
On develop: PRs #2970, #2988.
On develop: PR #3003 (FIMO-2791).
Branch feat/fimo-2804-publish-trigger, not merged. Migrations 0164/0165 clash with develop.
Branch feat/fimo-2792-geo-agent, not merged.
Dev Remote, triggers branch: a French story edit started a routine end to end. Rough edges in step 6.
Designed + spike. No code found.
Designed in mockups and Linear. Not built.
Smart triggers · how a rule works
The rule format, the words-to-rule compiler, the matcher and its noise controls.
Outcome
A routine can store one event trigger. People and agents only see two strings: the words and the explanation. The rule stays on the server.
The trigger lives in agent_thread_routines.event_trigger (jsonb) plus an event_trigger_on array of event kinds. The array is indexed, so one event costs one indexed lookup per project, however many routines exist.
The code is complete on the branch with unit, live-model and eval specs. It is not merged. Before merge, the migrations need new numbers, because develop already uses 0164 and 0165.
Rule anatomy
OR between clauses, AND inside a clause, one optional question
Each clause listens to exactly one event kind, so its fields are always clear. Anything more complex than one level of any becomes a Jev question.
RoutineTriggerRuletriggers/rule.ts{
"version": 2, // 2 = at least one clause asks Jev
"when": [ // OR: any clause starts the routine
{
"on": "content.changed", // one event kind per clause
"where": [ // AND: every condition must be true
{ "field": "contentType", "op": "eq", "value": "blogPost" },
{ "field": "locale", "op": "in", "value": ["fr", "es"] },
{ "any": [ // one level of OR inside a clause
{ "field": "changedFields", "op": "includes", "value": "body" },
{ "field": "changedFields", "op": "includes", "value": "title" }
] }
],
"judge": { // optional: a yes/no question for Jev
"question": "Does this post mention a competitor?",
"guidance": "Competitors are Contentful and Sanity (the CMS) ...",
"threshold": 0.7
}
}
],
"limit": { "runsPerDay": 24 } // hard cap, at most 24
}- Operatorseq · in · not_in · includes (lists) · gt · gte · lt · lte · matches (glob)
- entry.<field>Reads the entry's current value at match time (FIMO-2806)
- Limits≤ 10 clauses · ≤ 10 conditions per clause · runsPerDay ≤ 24
- Version2 if and only if a clause has a judge. An old worker refuses v2 instead of running on every match
Worked examples
Three more shapes: a value, a question, a chain
These are built from the schema on the branch. The comment line is the words a person would type.
"When a product's price changes to over 100"code only, free{
"version": 1,
"when": [ {
"on": "content.changed",
"where": [
{ "field": "contentType", "op": "eq", "value": "product" },
{ "field": "changedFields", "op": "includes", "value": "price" },
{ "field": "entry.price", "op": "gt", "value": "100" } // current value
]
} ],
"limit": { "runsPerDay": 24 }
}"When a big company submits the contact form"code filter + Jev{
"version": 2,
"when": [ {
"on": "form.submitted",
"where": [ { "field": "formId", "op": "eq", "value": "contact" } ],
"judge": {
"question": "Is this request from a company with more than 200 employees?",
"threshold": 0.7
}
} ],
"limit": { "runsPerDay": 24 }
}"After the SEO check finds something, or when a publish fails"chain + OR{
"version": 1,
"when": [
{ "on": "routine.finished", "where": [
{ "field": "routineId", "op": "eq", "value": "<seo-check-id>" },
{ "field": "outcome", "op": "in", "value": ["reported", "decision"] } ] },
{ "on": "site.publish_failed" } // OR: also when a publish fails
],
"limit": { "runsPerDay": 10 }
}Compiler
One model call turns words into a rule, or refuses with a reason
The compiler is google/gemini-3.5-flash, one structured call, 20 s timeout, no retry loop. It gets the project's real content types, forms, locales and routines, so it cannot invent a field.
A refusal goes back to the agent. The agent rephrases or tells the person. Fimo refuses what it cannot see as an event: traffic, rankings, other websites, times (use a schedule), "before publish" and before/after comparisons.
refusaltriggers/compile.ts · SCHEDULE_INSTEAD// "Run this when our competitor changes their pricing page"
{
"refused": "routine_trigger_unsupported",
"message": "Fimo cannot see changes on other web sites. Use a schedule instead:
the routine runs at set times, checks this itself and stops when
there is nothing to do."
}Judge
Jev reads one short document per item and answers all questions at once
Jev is a very small model. It cannot ask for more context, so Fimo builds the text: the event line, the changed fields, and the entry text, cut at about 8k tokens. One call per entry answers every question of a routine. Answers are stored, so a retried job asks only what is still open.
sequenceDiagram participant F as Fire job participant D as Decisions table participant J as Jev F->>D: 3 matched entries: answered? D-->>F: entry A: yes F->>J: entries B, C (all questions) J-->>F: B 0.82 · C 0.41 F->>D: store B, C Note over F: run with A, B · C marked unsure
A retried job never pays twice for the same answer.
- ≥ 0.7Matched: the run starts
- 0.35 – 0.7Unsure: the run starts and is told to check first
- < 0.35No run
- ≤ 50Entries judged per fire
Noise control
A routine never starts itself, and people's changes are told apart from routines'
Routine runs write as their own actor (FIMO-2766). So the matcher knows who changed what, and most kinds react only to people's changes. Without this, a routine that edits a post would start itself again on its own edit.
flowchart LR A[Routine A
edits a post] -->|cause: routine A| E[content.changed] E -.-x|own change| A E -.-x|people only| B[Routine B] P[Person edits] --> E2[content.changed] --> B A2[Routine A ends] -->|routine.finished| C[Routine C] C -.-x|one hop| D[Routine D]
Three guards: own change, people only, one hop.
- Self-loopA routine never starts from its own change or its own end
- Chainsroutine.finished is one hop: a chained run's end starts nothing
- People onlycontent, assets, code, schema, forms, merges, locales: only people's changes
- Main onlyMost kinds react on
mainonly - Daily caprunsPerDay counted on the run thread over 24 h
- DedupOne job per routine + event id; a changed trigger revision skips stale jobs
A real run
One edit to a French story started the routine, end to end
On Dev Remote we changed the French title of one story with the CLI. The routine started from content.changed, worked for 5 min 54 s, and wrote its report. We set the publish trigger too, but did not fire it: one run cost about 2,200 credits.
Code map
Where to read the code
All paths are on the triggers branch at ab5b56d. Open a row to read the file. The triggers folder is new; the routines tool already exists on develop with its trigger parameter.
The agents we build next
Six specialist agents from the marketing site, built as chats with routines and smart triggers.
Outcome
The marketing site (feat/marketing-site, src/data/agent-team.ts) promises six agents and 28 routines. That list is our backlog. No agent is configured on that project yet: it is copy, not product.
Each default agent ships as a code template: a starting prompt, skills and routines. A routine that runs "on publish" ships with a stored, compiled rule, like a lock file, so a new project needs no compiler call (FIMO-2809). New projects get default agents with routines paused.
SEO is done. GEO is on a branch. Site health, Writer and Translator are placeholders and will be removed (FIMO-2797).
Today
The SEO agent shows the pattern
The SEO agent is on develop. Its daily monitoring routine crawls the site, stores a per-page snapshot in the routine's thread, and reports only the diff: pages gone, redirected, no-index, new findings. A quiet run reports nothing.
The library
Six agents, and where an event trigger replaces a schedule
"On publish" routines need the event trigger. Until it merges, the plan runs them daily, which costs more and reacts later. Counts are routines per agent, from the marketing site.
- Daily
- technical SEO, broken links, monitoring
- On publish
- meta tags, image SEO, hreflang
- Weekly / monthly
- keywords, internal links, cannibalization
- Weekly
- AI visibility, citation optimization
- On publish
- structured data, AI readiness
- Daily
- competitor monitoring (needs the watcher)
- Weekly
- refresh, SEO writing
- On publish
- translation
- Daily
- Core Web Vitals
- On publish
- image compression, mobile
- On publish
- contrast, headings and labels
- Weekly
- keyboard navigation
- On publish
- brand compliance
- Weekly / monthly
- cookie consent, legal pages
One routine
A default routine is instructions plus a stored trigger
This is the shape a template ships. The person sees the name, the instructions and the explanation.
default routine (template)"Meta tags · on publish"{
"name": "Meta tags",
"instructions": "Check titles and descriptions on the pages this publish changed ...",
"schedule": null,
"event": {
"text": "After each publish",
"explanation": "Runs after each publish of the site.",
"rule": { "version": 1, "when": [ { "on": "site.published" } ],
"limit": { "runsPerDay": 24 } } // stored: no compiler call
}
}Website watcher
Watch pages on other websites and start a routine when the part you care about changes.
Outcome
You tell the agent: "watch Contentful's pricing page". The agent builds a watch spec: the part of the page, what to ignore, and the values to extract. The watcher checks the page on time and sends external.changed only when that part changes.
The watcher is only a new event source. It has no rules, judge or run path of its own. After the event, it is the same engine as internal triggers. So "a price changes" is a code check on values, and "a new feature claim" is a Jev question on the diff.
We build it in-house instead of Firecrawl Monitor: about $10–20 per month instead of $180–240 for 10k pages a day (FIMO-2765). A spike on 200 real competitor pages set the approach (FIMO-2787). Linear marks the issues Done, but no watcher code exists on develop or on any branch. Please confirm the status before you rely on it.
Design
One engine, two event sources
Site events come from our bus. Watched pages come from the watcher. Both feed the same trigger rule.
flowchart LR B[Project events] --> R[Trigger rule] W[Watcher] -->|external.changed| R R --> F[Code filters] F --> J[Jev, optional] J --> RUN[Routine run]
From watcher-plan.md: the watcher must not grow its own rules or run path.
Watch spec
A small, closed language: one selector, ignores, named values
CSS only, one each level, no scripts. The hash covers the selected part after ignores. Two projects watching the same URL with the same spec share one fetch.
watch specwatcher-plan.md{
"version": 1,
"url": "https://contentful.com/pricing",
"select": "main [data-section=pricing]", // one CSS selector: the part we watch
"ignore": ["time", "[data-nonce]", "re:/\\d+ teams signed up/"], // noise
"extract": { // named values to compare
"plans": { "each": ".plan", "fields": { "name": "h3", "price": ".price" } }
},
"render": "auto" // auto | never | always
}external.changedwhat a rule can test{
"on": "external.changed",
"kind": "values",
"url": "https://contentful.com/pricing",
"values": {
"plans[Lite].price": { "before": "$300 / mo", "after": "$489 / mo" }
}
}Change rules
Most checks stop early. Only real changes make an event
This is what keeps the noise low: most checks end at the first test and store nothing. Only a change in the watched part makes an event, and only a value change carries before and after.
flowchart TB
F[Fetch page] --> X{Selector found?}
X -->|no, 2×| B[external.broken
repair spec]
X -->|yes| H{Hash same?}
H -->|yes| S[Stop]
H -->|no| V{Values same?}
V -->|yes| C[content change]
V -->|no| D[values change
before + after]
From watcher-plan.md.
- Hash sameStop. Nothing stored
- Hash differs, values sameSnapshot, external.changed { kind: content }
- Values differSnapshot, external.changed { kind: values, before, after }
- Selector finds nothing 2×external.broken once; the spec builder repairs it; tell the chat once
- Blocked 3×Move the page to the fallback fetcher and record the cost
Product
Four moments in the chat
Mockups from the planning work. They show the intended product, not a build: ask, see the routine and its pages, get a change with before and after, and open one watched page.
How triggers scale
From an event to an agent turn, which parts carry the load, and the known limits.
Outcome
An event costs almost nothing when no routine listens. Each event kind has one listener per API process, not one per routine. The listener does one indexed lookup per project: routines whose event_trigger_on contains that kind.
A match becomes a QStash job. Any API pod can take it. The job reads the entries, asks Jev if needed, and puts a message on the routine's run-thread queue in Postgres. One thread runs one turn at a time; different threads run in parallel across pods.
We have not measured capacity. The known gaps: no outbox (a crash can lose an event), no per-project flow control on routine jobs, and no batch window yet.
Path
Event → listener → QStash job → thread queue → agent turn
Every hop is one that already exists for chats and schedules. Triggers add the listener and the fire job.
flowchart TB P[API write:
publish, edit, form] --> L[Listener per kind
in-process] L --> Q[QStash
routine-fire job] S[Minute sweep
schedules] --> Q Q --> F[Fire: limits,
entries, Jev] F --> T[Run-thread queue
Postgres] T --> A[Agent turn
sandbox + tools] A --> C[Agent chat
if worth it]
Solid boxes on develop; listener and Jev on the triggers branch.
Who does what
API pods compute, QStash delivers, Postgres owns order
More pods add compute, up to the shared limits of the database, model providers and sandboxes. A burst multiplies: 100 events with 3 listening routines make 300 fire jobs, because there is no batching yet.
Delivery
Compute
State
Watcher (plan)
The watcher splits a control plane from a data plane
The plan keeps fetching off the API pods. Cloudflare Workers fetch and diff; the API only plans, stores and sends the result to each subscribed routine.
Control · API + Postgres
Schedule
Data · Cloudflare Worker + Queue
Back to API
Limits
What protects us today, and what is missing
This is a reading of the code, not a measurement. Green rows are mechanisms we saw in code. Amber rows are known gaps we accept for v1. Gray is not known.
QStash dedup id, provider message id, stored Jev decisions.
One live turn per thread through a database claim and lease.
runsPerDay ≤ 24, 50 items per run, 50 judged entries per fire, 15 min schedule floor.
No outbox: an event lost before handoff is lost. Accepted for v1.
No per-project flow control on routine jobs; no 60 s batch window yet.
No load test, replica count or provider quota checked.
Code map
Where the scaling decisions live
Paths on the triggers branch at ab5b56d. The listener is in the trigger engine tree in step 2.
v1 will be rough · how we iterate
What we expect to get wrong, how we see it, and the two areas we change first.
Outcome
The first agents will run too often, say too little or too much, and ask questions in a way that does not scale. That is expected. The goal is a short loop: ship to internal projects, read real runs, change prompts and grouping, ship again.
Two areas need the most work. First, questions: with many routines, decisions pile up and one form per question does not work. Second, the old Agents tab: config-file agents must move to agent chats before we remove the tab.
Everything on this page is designed or decided in Linear. None of the grouping or the migration is built yet.
Signals
We see what is wrong from runs, not from opinions
Every Jev decision and every refused trigger is stored. That gives us numbers per agent. We read them each week and change prompts, thresholds and grouping.
flowchart LR S[Ship to internal
projects] --> R[Read real runs
+ numbers] R --> C[Change prompts,
thresholds, grouping] C --> S
The loop for v1. Customers come after the internal canary.
- Refused triggersWhat people ask for that Fimo cannot do (FIMO-2805)
- Unsure rateHow often Jev is unsure, per routine
- False startsRuns that end with nothing to tell
- LoopsDaily cap or chain depth reached
- CostJev and agent cost per project per day
First run
Our first live run already shows eight rough edges
One Dev Remote session on the triggers branch: two triggers set, one refused, one real run. This is the list to fix or decide before customers see it. Nothing here is a surprise; it is why we ship to internal projects first.
- Run vs reportRun marked Quiet, but its text says a person must decide. The report did not reach the chat
- Settings cardsCards ignore the event trigger: "By hand" or schedule only
- Words vs explanationThe page shows only the technical sentence ("on the main branch, locale is fr"), not the person's words
- Side effectSetting a trigger on a paused routine turned it on and restarted its Monday schedule
- Hidden tool callThe chat shows the agent's summary, never the trigger it set
- Time zones"Every Monday at 08:00 (UTC) · next Mon 10:00" mixes UTC and local
- ScopeA title review also changed the site template, merged to main and reported to Fimo support
- Two Agents surfacesThe old Agents tab does not list routine runs
Questions today
One question is one form in the chat
A routine never asks. It reports a decision; the agent then asks with a form. A question never expires. When it is not the last message, it folds to one line and stays answerable (FIMO-2713).
Grouping ideas
Three ways to show 12 decisions at once
These are the options to test on real runs. A planned Needs you list above the input shows the top 3 open items (decision, change to review, connection to make).
Old Agents tab
Config-file agents move to agent chats, then the tab goes
Production has 1,534 deployed config-file agents. 1,419 are welcome-guide copies with nothing to move. 88 real agents with runs, in 31 organizations, need migration; 75 ran in the last 30 days.
- Becomes
- the chat's starting prompt
- Becomes
- the routine's instructions
- Becomes
- routine triggers
- Run history
- not kept
flowchart LR A[Routines in
production] --> B[Internal projects
canary] B --> C[4 heavy customers
plan + reviewed apply] C --> D[Long tail
batch] D --> E[Tell owners] --> F[Remove the tab]
Order from FIMO-2721. apply pauses the old agent in the same transaction that creates the routine (FIMO-2817).
Plan and status
The Linear project Multi-chat, grouped by state on 12 Oct.
Outcome
One Linear project holds all of this: Multi-chat. Its idea: the chat is the only concept. An agent is a chat you keep on purpose, with a starting prompt, routines and memory.
The order is: several chats, routines, results reach the chat, site-event triggers, open questions, memory and reports, then ready-made agents, people outside Fimo, and activity.
Linear status is shown as written. Where code says otherwise (the watcher), the row says so.
Done
Shipped on develop
The base of the work. These are merged on develop; production follows the normal release train.
Triggers
Trigger issues: Done in Linear, waiting to merge
Linear marks most of these Done, but all of them live on the triggers branch. Merge needs the migration renumber: 0164 and 0165 clash with develop.
Branch.
Branch.
Branch.
Needed to stop self-triggers.
Lock-file style.
In review.
In review.
In progress.
Next
In progress and next
What the team picks up next. Gray rows are not started in code, or their Linear status does not match the code.
Branch, 4 commits.
PR #2891; Needs you list not built.
In progress.
Todo.
Linear: Done. Code: none found.
Backlog, inventory done.
Backlog.
Backlog.