Build it visually
Open a project’s Workflows tab and choose New workflow, or Edit on an existing one. The builder is a canvas: the workflow runs from the Trigger at the top, down through its steps.
Everything works from the keyboard. Tab moves through the palette, the toolbar, the steps on the canvas and the edit panel. Enter selects a focused step, and the arrow keys move it. The Edit menu jumps to any step. Shortcuts: Ctrl+Z undo, Ctrl+Shift+Z (or Ctrl+Y) redo, Ctrl+D duplicate, and Delete removes the selected steps while the canvas has focus.
Deleting a step from the middle of a chain reconnects the steps on either side of it. Duplicating one step puts the copy straight after the original. Step positions are not saved: opening a workflow, or Auto-layout, arranges it from its structure, with branch arms to the right of the branch.
Checked before you save
The builder checks the workflow as you edit and marks each problem on the step that has it, with a list above the canvas. Save stays blocked until there are none. It checks for:- an entry dataset on the trigger, and at least one step connected to it;
- steps that nothing connects to the trigger (they would never run);
- the step limit (32, counting steps inside branches) and branch nesting (3 levels);
- a
delayorwait_for_eventinside a branch; - each step’s own fields, such as a missing or non-https URL, a reserved header, instructions over 8,000 characters, a delay over 365 days, or a branch condition path that is not in the context (see below).
Runs on the graph
Every workflow’s builder has a Run history section. Choose a run and each step on the canvas shows what happened to it in that run: completed, running, waiting, failed or skipped. Steps inside a branch show which arm ran. Below the graph, a table lists every step with its start and finish times and any error. Anemit_event step’s details also list the delivery_ids it queued, so you can open each delivery.
A run that is pending, running or waiting can be stopped with Cancel run. A run that failed, was cancelled or timed out can be started again with Retry run. Both are also in the Instances dialog on the workflow list, which has a Show on the graph button for each run.
Branching
Abranch step evaluates one or more conditions against the instance context, using the same condition shape used everywhere else in Hookie (see Conditions), and runs then when they match, else otherwise. Because conditions read the context, you can branch on anything an earlier step produced: score a lead with AI, then route high-scorers one way and everyone else another. Branches nest up to 3 levels deep, and every taken path is recorded in step history so you can see exactly which way an instance went.
Where a branch condition’s path points
This is the one thing worth getting right before you write a branch, because the two condition fields on a workflow read different things:
The context is a scratchpad seeded from the entry record, and the record sits under
entry there. Alongside it are the keys earlier steps wrote: call_ai puts its reply under its output_key (default ai_result), agent_call under agent_result, http under http_result, transform under transformed, set under each key it sets, and wait_for_event under waited_event. So "entry.tier" reads the incoming lead, and "score" reads what the AI step just produced — as in the example below.
Branch sub-steps run synchronously: every step type except delay and wait_for_event, which cannot sit inside a branch; put them after the branch instead. This keeps a branch’s execution atomic and its outcome immediately visible on the graph.
Branching on an AI answer
gt, gte, lt and lte compare numbers, and a model replies with text, so what a call_ai or agent_call step puts in the context matters. A reply that is a JSON value is stored as that value; anything else is stored as the text the model wrote. So "8" becomes the number 8 and {"score": 8, "reason": "…"} becomes an object, while "Looks like a good fit" stays a string.
Models do not always answer with bare JSON. A reply wrapped in a ```json fence, or introduced with a line such as “Here is the JSON:”, is stored as the JSON inside it: the first fenced block that parses, otherwise the first {…} or […] in the text that parses. A number inside a sentence (“8 out of 10”) stays text. The run’s step history says what each reply became (output_type), and flags json_parse_failed when a reply had a fence or a brace but no JSON could be read from it — check there first when a branch on an AI answer always takes else.
That gives you two shapes that work, and it is worth picking one deliberately:
- Ask for the bare value: “reply with the number only” with
"output_key": "score", then branch on{ "path": "score", "op": "gt", "value": 7 }. That is the example above. - Ask for an object: “reply with JSON: score and reason” with
"output_key": "ai", then branch on{ "path": "ai.score", "op": "gt", "value": 7 }. Conditions take dotted paths, so the reason travels with the score and both are visible in the instance context.
output_key itself: path: "score" would resolve to the whole object, and a numeric comparison against an object is false. That one is still accepted, because the path resolves; it is the value that cannot compare. The instance context is shown in the run’s details, so check there first when a branch always goes the same way. Secret-looking values (credential-named keys, Bearer …/Basic … values and URL passwords) are shown as [redacted] there, in the step samples, over MCP and in the workspace export. The run itself always uses the real values.
output_dataset is unaffected by any of this: it always records the model’s reply as the text it actually wrote, so the audit trail is what was said, not what was inferred from it.
Conditions
Every place Hookie takes conditions uses one shape and the same ten operators: routing rules, endpoint criteria, workflow entry conditions,branch and wait_for_event conditions, WebSocket triggers and AI triggers.
branch and wait_for_event conditions, a condition can also be a group. {"any": [...]} holds when one member holds, and {"all": [...]} holds when every member does. A member is a condition or another group, and groups nest at most two levels deep. A rule, an endpoint’s criteria or a trigger refuses a group with 400, because it tests one condition at a time.
- Where equality is exact, and where it is text. In workflows and triggers,
equals,not_equalsandincompare exactly, type included:5and"5"are different values. In rules and endpoint criteria they compare as text, as rules always have, so5and"5"are equal. - Numbers sent as text still compare.
gt,gte,ltandltematch a field that is a number, and a field that is text spelling a plain decimal number: an optional+or-, digits, and an optional fraction, with spaces around it ignored. So an HTML form’s"150"is 150, and a feed that sends a price as"60000"matches{ "path": "price", "op": "gt", "value": 50000 }. Any other text never matches, including hex ("0x10"), exponents ("1e3"), thousands separators ("1,000") and empty text. The value you compare against must still be a number.equals,not_equalsandindo not change: in workflows and triggers"150"still does not equal150. - Text stays text.
containsandregexmatch only a field that is a string.
400 that names the condition, rather than stored and silently never matching. That covers an unknown operator, a condition missing its value, both the shorthand and op/value at once (such as {"path": "status", "op": "equals", "equals": "paid"}), a key Hookie does not know, a numeric operator against anything but a number, contains or regex against anything but a string, an in without a list of 1 to 100 plain values, and a pattern outside the bounds below.
A condition’s value can be a {{placeholder}} only in a branch or wait_for_event, where there is an instance context to read it from. Anywhere else it would be compared as the literal text {{…}}, so it is refused. The values in an in list are never resolved, so a placeholder there is refused too.
Regular expressions
Aregex value is a JavaScript regular expression, written without the surrounding slashes. It matches anywhere in the field unless you anchor it with ^ or $, and it is case-sensitive, so use a class such as [Pp]aid to match either case.
Patterns run against every matching event on a shared service, so each one is bounded to keep a single event cheap:
- At most 200 characters, and the pattern must compile.
- No backreferences, such as
\1or\k<name>. - No nested quantifiers. A repeated group that itself repeats or alternates, such as
(a+)+,(a|b)*or(\w+\s?)*, is refused. - At most 2 open-ended repeats:
*,+,{n,}, or{n,m}withmabove 16..*paid.*usdis accepted;.*a.*b.*cis not. - Only the first 256 characters of the field are tested.
Defining a workflow
Build workflows on the canvas under Project → Workflows, or send the definition to the API. A workflow’s menu also has Rename… and Activate or Deactivate, which change nothing else and keep its version, and Delete, which removes the workflow and its run history for good (see Deleting a workflow). Over the API,PATCH /admin/api/projects/{project_id}/workflows/{id} merges what you send into the stored definition, so {"active": false} alone switches a workflow off, while PUT replaces the whole definition. The update_workflow tool and hookie workflows update send the whole definition. Either way it is this JSON; the builder’s Definition (JSON) panel shows it for the workflow you are editing. Entry conditions use the shared condition shape; an empty array matches every record in the dataset.
Step types
Waiting for this run’s record
Await_for_event condition’s value can name the run’s own context. Hookie resolves it once, when the run starts waiting, so the wait matches the record that belongs to this run and no other:
approvals would take the first approval that arrived, whichever order it was for. With it, an approval for order B2 resumes the run that started from order B2 and leaves the others waiting.
The placeholder has to be something the run has at that point: the entry record (entry.…) or a key an earlier step wrote. A placeholder that could never resolve is refused when you save. If the value is empty for a particular run (that entry record has no order_id), the run fails when it reaches the wait, rather than waiting for any record at all.
When nothing arrives
"on_timeout": "continue" turns a timeout into an answer. The run carries on with waited_event set to null, so a branch on { "path": "waited_event", "op": "exists" } takes then when the record came and else when it did not — “nobody approved in 24 hours, so escalate”. Without on_timeout, or with "fail", a timeout ends the run as timed_out, as before.
AI agents
An AI agent is a reusable, named LLM persona: a model, a system prompt, a bounded token cap, and an optional memory dataset that every output is also written to for recall. Define agents next to your workflows in the console, then reference one from any workflow with anagent_call step, including inside a branch. Update the agent once; every workflow that uses it picks up the change.
Deactivate an agent from its menu to switch it off: its definition is kept, a workflow step that names it fails until you Reactivate it, and nothing else changes. Delete removes it for good, after a warning; a step that names it then fails with not found. Over the API, DELETE /admin/api/projects/{project_id}/ai-agents/{id} only retires the agent, as the delete_ai_agent tool does, and ?permanent=true removes it.
Templating
emit_event payloads, http, set and transform read the instance context with {{path}}, and so do the values of branch and wait_for_event conditions. A value that is exactly "{{entry.email}}" keeps its type (a number stays a number, an object stays an object). Inside longer text, as in "https://api.example.com/users/{{entry.id}}" or "Ticket {{entry.id}}: {{triage.summary}}", the value is inserted as text; in a URL it is also URL-encoded, and it may only appear after the host.
In an emit_event payload, a field whose whole value is a placeholder that resolves to nothing for a run keeps the entry record’s value for that field, or is left out when the entry record has none. The step’s details list such placeholders under unresolved_placeholders. A placeholder whose first part is not something the run can have (entry, waited_event, or a key an earlier step writes) is refused when you save, and the error lists the keys that do exist.
Durable execution
Workflows run on Cloudflare Workflows. Each step is durable: a step that fails on a transient error is retried, a run survives restarts, anddelay and wait_for_event suspend the run without holding anything open. A step that already completed is never run twice, so a retried run does not emit the same event again. An AI step is the exception: re-running it calls the model again, and counts against your AI allowance again.
Instances & observability
Each matching entry record starts an instance (a run). The console lists instances per workflow with a drill-down into the live context and a per-step history, and shows any run on the workflow’s graph (see Runs on the graph), so you can see exactly where a flow is and why it stopped. A running or waiting instance can be cancelled, and a failed, timed-out or cancelled one retried from the start, from the console, the API (POST /admin/api/projects/{project_id}/workflow-instances/{id}/cancel and …/retry), the CLI (hookie workflows cancel|retry) or a connected agent. Over the API, GET /admin/api/projects/{project_id}/workflows/{id}/instances takes state, since, until, limit and offset, and answers with a total.
A run’s context starts as a copy of the record that started it, so run history is kept for your plan’s retention window, like the records themselves: a finished run (completed, failed, cancelled or timed out) is removed, with its step history, once both its start and its last change are older than the window. A run that is pending, running or waiting is never removed. Deleting a workflow removes its finished runs at once, whatever their age.
Deleting a workflow
Deactivate switches a workflow off and keeps everything: it starts no new runs, runs in progress carry on, and Activate turns it back on. Delete, in the same menu, removes it for good after a warning: the workflow and its whole run history, meaning every run and its step history. Records and deliveries its runs produced, and the AI call log, are kept. A workflow with a run that has not finished (pending, running, waiting, or paused by a suspension) cannot be deleted. The refusal names up to 10 of those runs. Cancel them first (Instances → Details → Cancel run,hookie workflows cancel, or the cancel_workflow_run tool), and deactivate the workflow if it is still on so it starts no new ones.
Over the API, DELETE /admin/api/projects/{project_id}/workflows/{id} only deactivates, as the delete_workflow tool does by default. With ?permanent=true it deletes:
- It answers
200with{"ok": true, "done": true, "deleted": {"runs": …, "step_events": …}}. - It answers
409with"code": "unfinished_runs"and the runs ininstanceswhile a run has not finished. - A history of more than 10,000 runs is deleted over several calls. Each answers
202with"done": falseandremaining_runs, and sending the same request again carries on.
delete_workflow. With the tool, pass permanent: true. With the CLI, run hookie workflows delete --workflow-id … --permanent, which asks before it runs unless you pass --yes.
Limits
- Up to 32 steps per workflow (branch sub-steps count toward the total).
- Branch nesting depth: 3 levels.
- Active-instance cap per plan: 25 on Free, 500 on Pro, 2,000 on Team.
delayandwait_for_eventtimeouts are exact to the second, up to 365 days. A wait with no timeout gives up after 365 days.- A definition’s steps are at most 256 KiB of JSON, and an instance’s context at most 768 KiB.
setwrites at most 64 keys. - AI steps are bounded to 1,024 output tokens and 8,000 characters of instructions per step.
Start triggers
A workflow starts when a record lands in its entry dataset. Besides events sent to an endpoint or with an ingest key, two kinds of trigger land records on their own. Both are under Start triggers on the Workflows tab, and owners and admins can create them.Cron triggers
A cron trigger fires its payload as an event on a schedule: a 5-field cron expression (minute hour day-of-month month day-of-week) read in the trigger’s time zone. 0 9 * * 1-5 in America/New_York fires at 09:00 New York time on weekdays, on both sides of a daylight-saving change. Give the zone as an IANA name. The console fills in your browser’s zone, and the API defaults to UTC.
- The scheduler runs every 5 minutes, so a schedule whose firings are closer than that is refused.
*/5 * * * *is the finest one accepted. - At a daylight-saving change, a local time that happens twice fires once, at its first occurrence. A local time the change skips (02:30 on the night clocks go from 02:00 to 03:00) fires just after the gap instead of being skipped.
- Creating a trigger returns its next three fire times. To see them before saving, send
POST /admin/api/projects/{project_id}/cron-triggers/previewwithscheduleandtimezone. - Edit changes the name, schedule, time zone, payload or dataset. A new schedule or time zone moves the next run. Resuming a paused trigger also moves it, so a trigger does not fire the moment it is resumed for a time that passed while it was paused.
WebSocket triggers
A WebSocket trigger holds a connection to a feed open and turns every frame that matches its conditions into an event. The conditions are checked when you save, so a misspelled operator or agt against text is an error there. Before this change, the trigger was saved, reported connected, and never matched a frame. gt, gte, lt and lte compare numbers, and a price the feed sends as text ("60000") counts as one. See Conditions for exactly which text counts.
Mappings reshape each matching frame the way a rule’s mappings do: each { "path": "data.p", "key": "price" } picks a field out of the frame into a key of the event. With no mappings, the frame is kept as it arrived. The raw submission always keeps the frame exactly as received; the mapped event is what is filed into the trigger’s dataset, or handed to the project’s rules when the trigger names no dataset, and what default keeps when none of them matches. A mapping key must be a plain identifier (a letter, then letters, digits or underscores); anything else is refused with 400 when you create the trigger, rather than saved and then dropping every frame. Set mappings over the API (POST /admin/api/projects/{project_id}/ws-triggers), with a connected agent’s create_ws_trigger, or with hookie ws-triggers create --mappings.
- Edit changes the name, URL, connect message, conditions or dataset, each checked as it is when you create the trigger. A new URL, connect message, set of conditions or dataset reconnects the socket; a new name does not. Over the API,
PATCH /admin/api/projects/{project_id}/ws-triggers/{id}also takesheadersandsubprotocols, and a connected agent’supdate_ws_triggertakes every one of those fields. A trigger keeps themappingsit was created with; to change them, create a new trigger. - The URL, headers and connect message are stored encrypted and never shown again, so the Edit form leaves the URL blank: leave it blank to keep the current connection. A new URL is saved with the connect message you enter, and none if you leave it blank.
- Moving the URL to a different host is refused unless the request also sends each stored connection header and connect message (
nullfor none). They often carry the feed’s credentials, and Hookie will not pass them to a new host that nobody chose to give them to.
Triggers with no dataset
The Dataset field on a cron or WebSocket trigger is optional. If you set it, every cron run or matching frame is stored there as one record. If you leave it blank, each event goes through your project’s rules instead, and every rule that matches writes its own record. The trigger lists show such a trigger’s dataset as rules, else default. An event that no rule matches is not dropped: it is stored whole, as one record, in thedefault dataset. For a WebSocket trigger, “whole” means the event after the trigger’s mappings. That record is delivered to destinations subscribed to default, starts AI triggers and workflows on default, and appears in the live stream, just like any other record. Each fire counts as one event against your monthly quota, however it is stored.
If the event cannot be stored in default, it is not fanned out, and its raw submission is kept and marked Routing failed. The usual cause is an empty payload such as {}, which has no fields to store. The error reads no rule stored the event, and storing it in "default" failed: record has no fields:
- A cron trigger shows it as its last error, and a test fire (
POST /admin/api/projects/{project_id}/cron-triggers/{id}/test, orhookie cron-triggers test) answers500with it. - A WebSocket trigger shows it under its status on the trigger list, and in the
last_errorfield ofGET /admin/api/projects/{project_id}/ws-triggers. The next frame that is stored clears it. A feed that fails on every frame updates the error at most every few seconds, not once per frame. The frame’s submission is also under Routing failed on the Observability tab.
{"tick": "{{now}}"}, or set a dataset.
AI triggers
An AI trigger runs an AI agent when a matching record lands. Its reply is stored as a record in the trigger’s output dataset, and that record is fanned out like any other: delivered to the project’s destinations whose filter admits the dataset (counted against the delivery allowance), and sent to the live stream. It carries the hop count of the event that triggered it. It never fires an AI trigger and never starts a workflow, so a trigger that matches its own output cannot loop. If the model is briefly unavailable (busy, rate-limited, timing out, or failing on its side), the run is retried after about 30 seconds, then 2 minutes, then 10 minutes, and marked failed with the model’s error after the fourth attempt. While it waits, the run showsqueued and says why. A request the model refuses (for example, an unknown model) fails at once. A run refused because the month’s AI allowance is used up also fails at once and is not retried.
AI allowance and the AI call log
Every AI call counts the tokens it sends to the model and the tokens the model answers with against your plan’s monthly AI token allowance. That includes acall_ai step, an agent_call step and an AI trigger run.
A typical classification step uses about 1,800 tokens. The largest a single step can use is about 4,600.
Once the month’s allowance is used, the next AI call is refused before the model runs. The step fails with the reason, for example Monthly AI allowance used: 750,000 of 750,000 tokens on the Pro plan for 2026-09, and so does the workflow instance or trigger run. AI steps work again on the 1st (UTC), or straight away after an upgrade. Home shows this month’s usage against the allowance, with a warning from 80%.
Every call is logged in the project’s Observability → AI calls. Each entry records:
- the step, agent or trigger that made the call, and the model;
- the instructions, the input and the model’s output, each kept up to 8,000 characters and marked when cut;
- the tokens in and out, the latency, and whether the call succeeded, failed or was refused.