Start runs from webhooks
This guide makes a workflow start by itself when another system sends an HTTP request: a new ticket, a pushed commit, a paid invoice. At the end you have a workflow that accepts a webhook, checks that it is genuine, turns its contents into run parameters, and ignores repeats.
Before you start
Section titled “Before you start”You need a workflow you can run. Write your first workflow makes one.
How it works
Section titled “How it works”Two parts work together:
- A
triggerblock in the workflow says which requests start a run, and how to check them. realm listenis a small web server. For each request that passes the checks, it creates a run and starts a separaterealm agentprocess to drive it. It replies as soon as the run exists.
1. Add a trigger to the workflow
Section titled “1. Add a trigger to the workflow”id: ticketsname: Record a new ticketversion: 1
params_schema: type: object additionalProperties: false required: [ticket_id, subject] properties: ticket_id: type: number subject: type: string
trigger: type: webhook path: /tickets auth: mode: shared_secret header: X-Webhook-Token secret_from: TICKETS_WEBHOOK_TOKEN filter: all: - { path: body.type, value: ticket-created } dedup: id_from: body.id params_map: ticket_id: body.id subject: body.subject
steps: record: description: Record that the ticket arrived. execution: autoThe trigger has five parts:
| Key | What it does |
|---|---|
path |
The address the workflow answers on. Without it, the address is / followed by the workflow’s id. |
auth |
How a request proves it is genuine. Here the sender must put a secret in the X-Webhook-Token header. |
filter |
Which requests matter. Here only those whose body has type: ticket-created. |
dedup |
Where to find the event’s own ID, so that a repeated delivery does not start a second run. |
params_map |
Which fields of the request become the run’s parameters. |
In filter, dedup and params_map, body.… means a field of the request’s JSON body, and headers.… a request header.
The secret is not written in the workflow. secret_from names an environment variable that holds it.
2. Start the server
Section titled “2. Start the server”Set the secret and a model provider key, then start realm listen on the workflow’s folder, with the provider and the model:
export TICKETS_WEBHOOK_TOKEN=choose-a-long-secretexport ANTHROPIC_API_KEY=your-keyrealm listen ./ --port 3000 --provider anthropic --model claude-sonnet-5-5The command uses Anthropic and the model claude-sonnet-5-5; with an OpenAI key, use --provider openai --model <a model from OpenAI's list> (OpenAI’s models). Version 0.45.0 has no --provider or --model on realm listen: leave them out there.
It prints:
listen: mounted {"workflow":"tickets","path":"/tickets","mode":"shared_secret"}realm listen on 127.0.0.1:3000 — 1 workflow(s) mountedThe key and the model are needed because each run is driven by realm agent, which will not start without them, even though this workflow’s only step does not call a model.
Restart realm listen from the workflow’s folder after any change to it, an edited .env included: it reads the workflow, and the .env of the folder it is started in (or the file DOTENV_CONFIG_PATH names), once, at start; a key already set in the shell that starts it keeps its value, unless DOTENV_CONFIG_OVERRIDE is set in that shell (a line in that .env changes only how each agent loads its own folder’s .env; see realm listen).
You can give realm listen several workflow folders. Each must have a trigger and its own path.
3. Send a request
Section titled “3. Send a request”Send the kind of request the other system would send:
curl -X POST http://127.0.0.1:3000/tickets \ -H 'Content-Type: application/json' \ -H 'X-Webhook-Token: choose-a-long-secret' \ -d '{"type":"ticket-created","id":101,"subject":"Charged twice"}'It prints:
{ "run_id": "ed876b82-9365-4c63-9c6e-42ffed4d617e", "status": "accepted" }The status code is 202. The run now exists, and realm listen logs that it handed the run over:
webhook: dispatched {"path":"/tickets","run_id":"ed876b82-9365-4c63-9c6e-42ffed4d617e","pid":1883248}A moment later the run has finished:
realm run listed876b82-9365-4c63-9c6e-42ffed4d617e tickets v1 completed 10/2/2026, 12:30:21 AM 1 step(s)4. See what is turned away
Section titled “4. See what is turned away”Each check has its own reply. Send these to see them.
Without the secret, the request is refused:
{"error":"forbidden"} 403The same event again starts nothing. Within the duplicate window (ttl_minutes, 60 minutes by default) the reply is:
{"status":"deduplicated"} 200Later, while the run that event started exists (a sender’s redelivery, for one), and when two copies of one delivery arrive together, the reply names that run and its phase. Nothing is started and the run is not written to, whether it is still going or has ended. Here the run had already finished:
{"status":"deduplicated","run_id":"c1f1da1f-ce29-4bcd-b12b-1556c5698fc7","run_phase":"completed"} 200run_phase is the phase on the run’s record, not whether an agent is working on it: a run whose agent stopped still reads running.
An event the filter does not want, for example "type":"ticket-closed", starts nothing:
{"status":"ignored"} 200Contents that do not fit params_schema, for example "id":"abc", are refused with the reason:
{"error":"params_invalid","message":"Invalid params for workflow 'tickets': /ticket_id must be number","status":"rejected"} 400In none of these cases is a run created or a realm agent started. The 200 replies are deliberate: the sender did nothing wrong, so it should not retry.
An agent that cannot be started (the workflow’s folder missing, or a folder realm listen has no permission to enter) ends the run the request created as failed, and the reply says so; realm listen goes on serving. The same event again starts nothing and gets the same reply:
{"error":"spawn_failed","run_id":"726707a4-a0f5-4b98-bff6-8edfe9c3aba8","status":"failed"} 500This 500 is deliberate: the event’s work never ran, and each retry of that delivery gets the same reply until you deal with the run.
One case does not behave this way yet: two copies of one delivery arriving together while the agent fails to start. One copy can be answered 200 {"status":"deduplicated","run_id":"<run-id>","run_phase":"running"}, and its ID is recorded. Retries then get the bare {"status":"deduplicated"} until the duplicate window has passed, then the 500 again, and purging the run and delivering again starts a new run only after the window.
To give an event’s run another go:
- A run still going whose agent stopped: drive it again with
realm agent --run-id <run-id>in the workflow’s folder. - A run that has ended is not driven again:
realm agent --run-idrefuses it. - A run whose agent failed to start: remove it with
realm run purge <run-id> --force, then deliver the event again. It starts a new run at once, unless two copies arrived together (the case above): then it starts one only after the duplicate window. - A run that ended any other way: purge it, then deliver the event again once the duplicate window has passed. The window starts again each time a delivery of the event is matched in the meantime, so it is not counted from the first delivery. (With the default file store and a
ttl_minutesunder 60, a delivery matched in the same clock hour, UTC, as the last one recorded does not start it again.) Until then the delivery gets{"status":"deduplicated"}and starts nothing.
Choose how requests are checked
Section titled “Choose how requests are checked”auth.mode has five values. Pick the one the sending system supports.
| Mode | The sender proves itself by |
|---|---|
shared_secret |
Putting a fixed secret in a header you name. |
github |
Signing the body the way GitHub does, in X-Hub-Signature-256. |
stripe |
Signing the body the way Stripe does, in Stripe-Signature. |
hmac |
Signing the body with a secret, in a header and algorithm you name. |
none |
Nothing. Use only on a network you trust; realm listen warns about it. |
Running it for real
Section titled “Running it for real”realm listenaccepts connections from this machine only, unless you set--host. To receive webhooks from the internet, put it behind a web server or tunnel that provides HTTPS.- It limits requests, not runs.
--max-concurrent(20 by default) is the number of requests being handled at once; past that it replies503. Each accepted request starts its ownrealm agentprocess, and nothing limits how many of those run at the same time. - Workflows with human gates need a way to be answered while no one is at a terminal. See Answer gates from Slack.
- Gates with a time limit are carried out only when something acts on the run. Start
realm listenwith--sweep-expired-gates <seconds>to have it check on that interval.
If you see something else
Section titled “If you see something else”Error: listen: workflow 'tickets' references secret env var 'TICKETS_WEBHOOK_TOKEN' which is not setThe variable named insecret_fromis not set in the shell that startsrealm listen. Set it and start again.{"error":"forbidden"}for a request you believe is right. Check the header’s name and value, and the address: a path that no workflow is mounted on gets the same reply.
See also
Section titled “See also”- Webhook
triggerreference lists every key and everyauthmode’s settings. realm listenreference- Start runs safely covers starting runs without duplicates in general.