Flows
A flow is work that outlives one request: waiting for a person, trying again tomorrow, a long loop. wirl calls your module one step at a time and keeps the run's place in between.
Four things a person asks their agent for show what a flow is for, and this page builds each of them:
- "When someone asks for a refund over $500, wait for a manager to approve it, then issue it."
- "If the vendor's API is down, try that step again tomorrow."
- "Import these 5,000 rows a few hundred at a time."
- "Let the agent keep working on this for an hour."
A schedule run gets fifteen minutes at most and keeps nothing between runs. Each of these is a flow instead: a few steps of ordinary code, with no library to install.
How a flow runs#
Declare the flow in wirl.json:
{ "app": "refunds", "server": "server.js", "flows": [{ "name": "refund", "step_timeout": "5m", "retries": 5 }] }
name: 1 to 40 lowercase letters, digits or dashes. An app declares at most 20 flows.step_timeout: how long one step may take,"30s"to"15m". Defaults to"5m". A run as a whole has no time limit: it goes on for as many steps as it takes, within the limits.retries: how many more times a step that fails is tried, 0 to 10. Defaults to 5.
A flow refuses any other key, naming it, so a misspelt limit is never dropped in silence.
wirl then posts each step of a run to your module at /__wirl/flow/<name>, with a JSON body:
{ "run": "run_4vq…", "step": "start", "n": 0, "attempt": 1, "input": { "orderId": "41" }, "state": null, "event": null }
runis the run's id.stepis"start"the first time, and after that whatever your last answer named.nnumbers the run's steps from 0, andattemptcounts the tries of this one step from 1.inputis what the run was started with, on every call. Nobody is signed in during a step, since a flow is not a person, so who asked for the run belongs ininputor in the row the run is about.stateis whatever your last answer sent, andnullwhen it sent none.eventis what a wait heard, at the step after the wait, andnulleverywhere else.
The request carries x-wirl-app-id and x-wirl-org as every request does, the four visitor headers empty, and five of its own:
x-wirl-flow: the flow's name. wirl sets it and removes any a caller sent, so refuse a call whosex-wirl-flowis not the flow you answer.x-wirl-run,x-wirl-stepandx-wirl-attempt: the run, the step's name and the try, as in the body.x-wirl-idempotency-key:<app id>:<run>:<n>, the same on every try of one step, for your own database writes. Thefailedandcancelledcalls (below) have their step's name after it.
A module that answers one flow starts like this:
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/__wirl/flow/refund") {
if (request.headers.get("x-wirl-flow") !== "refund") return new Response("not this flow", { status: 404 });
return Response.json(await refundStep(await request.json(), request, env));
}
// ... the app's pages
},
};
What a step answers#
Answer 200 with one of these as JSON:
| Answer | What wirl does |
|---|---|
{ "next": "charge", "state": {...} } |
runs charge now, with this state |
the same, with "sleep": "1d" |
runs charge a day later |
the same, with "until": "2027-01-04T09:00:00Z" |
runs charge at that time, which must name its zone (Z, or an offset like +02:00) |
the same, with "wait": { "event": "decision", "timeout": "3d", "otherwise": "expire" } |
runs charge when an event named decision arrives, handing it over as event; if three days pass first, runs expire instead |
{ "retry": "1d", "state": {...} } |
runs this same step again a day later: the same n, the same idempotency key, and attempt one more |
{ "done": true, "output": {...} } |
finishes the run |
{ "fail": "why" } |
stops the run, failed, with that reason |
- A
nextanswer takes at most one ofsleep,untilandwait. A duration is a whole number and a unit:"30s","10m","2h","1d". A sleep, a wait's timeout and aretrydelay are each at least 30 seconds and at most 30 days. Anuntilmay be at most 30 days away, and anuntilsooner than 30 seconds away is taken as 30 seconds. otherwiseis required: an answer that waits with nothing to run at its timeout fails the run, saying so. A timeout never runsnext, so the step after a wait only ever sees the event it waited for.- A step's name is 1 to 100 letters, digits, dashes or underscores, and never
failedorcancelled, which are wirl's (below). An event's name is 1 to 100 of the same characters. - A key an answer does not take is refused by name:
{ "next": "charge", "slep": "1d" }stops the run rather than runningchargea day early. stateis only what an answer sends, so send it with everynextand everyretry: left out, the next call getsnull. Keep it to ids and cursors, since the whole answer must stay under 1 MiB. Rows, messages and anything secret go inenv.DB. An answer, a run's input and an event's body each nest at most 100 levels of objects and arrays.- A step may answer
retryat most 30 times; one more fails the run.
A step that does not answer one of these ends one of two ways:
- A blip. It throws, runs past its
step_timeout, or answers5xx. wirl tries it again,retriestimes, after 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, and 6 hours before each try after those. ARetry-Afteron a5xxanswer is honoured instead, held between 10 seconds and 6 hours. After a try that ran out of time, the next waits at least 90 seconds, since the first may still be running. When the tries run out, the run fails with the last error. - A mistake. It answers a status that is neither
2xxnor5xx, a2xxthat is not JSON, JSON that is none of the answers above, or more than 1 MiB. The run fails at once, and its error says what came back.
When a run fails or is cancelled, your module gets one more call, with step "failed" or "cancelled" and a reason, so it can mark its own rows: state is null on a cancel, input is there as ever, and the answer is ignored. Neither call is made once the run's code is gone: when the app is deleted, when a preview is deployed again, removed or expires, or when a deploy no longer declares the flow (below). Answer fail to any other step you do not know, since a deploy between two steps can hand new code a step name only the old code knew.
A step can run twice#
A try that ran out of time may still be running when the next begins, a try whose answer was lost is made again, a step in flight when wirl itself is redeployed is called again about a minute after its step_timeout is up, even when the run has finished meanwhile, and a retried run (below) runs its steps again under the same keys. So:
- write to
env.DBwith upserts (insert ... on conflict (...) do update), whoseon conflictnames the table's primary key or a unique index, or key a write on the step'sx-wirl-idempotency-key; - give a vendor call an
Idempotency-Keynamed for the operation (refund-41), not for the try, so the vendor acts once however many times the step runs, for as long as the vendor keeps the key (see The vendor that is down). wirl never passes a vendor a header startingx-wirl-, so this is one your code sends.
The refund approval#
When someone asks for a refund over $500, wait for a manager to approve it, then issue it.
Four pieces: a page that takes the request and starts a run, a first step that emails the manager and waits, an approval page the manager answers on, and the steps after the wait. The refunds are rows in env.DB, one per order, and the connections are Stripe's and a mail provider's (a person adds each one's key on the app's keys page; see Connections):
{
"app": "refunds",
"server": "server.js",
"connections": [
{ "slug": "stripe", "hosts": ["api.stripe.com"] },
{ "slug": "resend", "hosts": ["api.resend.com"] }
],
"flows": [{ "name": "refund", "step_timeout": "1m" }]
}
The order is the table's key, which the first step's on conflict (order_id) needs. A preview's database starts empty, so the module makes the table before it routes any request, a step's included, as the contract page's migrate(env) does:
const SCHEMA = [
"create table if not exists refunds (order_id text primary key, payment_intent text not null, amount integer not null, requested_by text, status text not null, decided_by text)",
];
let migrated = false;
async function migrate(env) {
if (migrated) return;
await env.DB.exec(SCHEMA.join("\n"));
migrated = true;
}
The module's fetch makes the table, then hands each request to its piece, each of them below:
export default {
async fetch(request, env) {
await migrate(env);
const url = new URL(request.url);
if (url.pathname === "/__wirl/flow/refund") {
if (request.headers.get("x-wirl-flow") !== "refund") return new Response("not this flow", { status: 404 });
return Response.json(await refundStep(await request.json(), request, env));
}
const posted = request.method === "POST";
const approval = /^\/approve\/([^/]+)$/.exec(url.pathname);
if (posted && url.pathname === "/refunds/new") return askForRefund(request, env);
if (posted && approval) return answerRefund(request, env, approval[1]);
// ... the app's pages: the request form at /refunds/new, how a request is going at /refunds/<order>, and the
// approval page at /approve/<order>
},
};
Starting the run. The request form posts to the app, which starts the run from its own code at https://flows.wirl, which needs no connection. The input is the request, who asked included, since nobody is signed in during a step. The key names the run in the app's own terms, so a form sent twice finds the run the first one started. The app takes the form only from its own page, and answers a post from any other page with a redirect back to the form, starting nothing, for the reasons Posts from another page gives, below. It starts nothing when nobody is signed in either, as can be so on a public app, since a refund is asked for by a person:
// From the app's own page a browser sends `sec-fetch-site: same-origin` (`none` from its address bar) and, with a post,
// the app's `origin`, since wirl serves the app's pages with `Referrer-Policy: same-origin`. A post another page makes,
// a sibling app's or another site's, says `same-site` or `cross-site`, or names that page's `origin` or `null`, in any
// browser that sends either header. A request from outside a browser, such as `wirl fetch`'s, has neither.
const fromOwnPage = (request) =>
[null, "same-origin", "none"].includes(request.headers.get("sec-fetch-site")) &&
[null, new URL(request.url).origin].includes(request.headers.get("origin"));
// Who is asking. An address that is not plain ASCII arrives percent-encoded, as every name does, and so with no "@"
// in it; a plain one arrives as it is.
const emailOf = (request) => {
const sent = request.headers.get("x-wirl-user-email") || "";
return sent.includes("@") ? sent : decodeURIComponent(sent);
};
async function askForRefund(request, env) {
if (!fromOwnPage(request)) return Response.redirect(request.url, 303);
// A request from outside a browser passes that check, and on a public app it can come with nobody signed in.
const requestedBy = emailOf(request);
if (requestedBy === "") return new Response("Sign in to ask for a refund", { status: 403 });
const form = await request.formData();
const orderId = String(form.get("order"));
const input = {
orderId,
paymentIntent: String(form.get("payment_intent")),
// The form asks in dollars; the run, the row and Stripe count cents.
amount: Math.round(Number(form.get("amount")) * 100),
requestedBy,
};
const started = await fetch("https://flows.wirl/flows/refund", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input, key: `refund-${orderId}` }),
});
// The request's page shows how the request is going, from its run, and says to ask again when there is none; the log
// keeps why a start was refused.
if (!started.ok) console.log(`The refund for order ${orderId} could not start: ${await started.text()}`);
return Response.redirect(new URL(`/refunds/${orderId}`, request.url), 303);
}
A new run answers 201 with { "run": "run_…" }, and a key already used for this flow answers 200 with the same run (409 with "error": "key_in_use" for another flow's). A key is 1 to 100 letters, digits, dots, colons, dashes or underscores, unique among the live app's runs or one preview's, and never begins with run_ or schedule:, which are wirl's.
A key keeps naming its run until 30 days after the run ends, however it ended. So refund-${orderId} asks once for each order: once that run has ended, the same order sent again finds it and starts nothing, and the page shows how it went rather than asking a manager again. Where a request may be made again after its run has ended, key the run on the request rather than on what it is about: give each request a row of its own, and put its id in the key and in the approval link.
The first step writes the row, emails the manager, then waits. Amounts are in cents. A refund of $500 or less is issued at once; a larger one waits up to three days for a decision:
const MANAGERS = ["dana@acme.test"];
const refundOf = (env, order) => env.DB.prepare("select * from refunds where order_id = ?").bind(order).first();
const mark = (env, order, status, by) =>
env.DB.prepare("update refunds set status = ?, decided_by = coalesce(?, decided_by) where order_id = ?").bind(status, by, order).run();
async function refundStep({ step, input, state, event }, request, env) {
// A run started with no input gets `null`, and has no order to refund: fail it, saying so, where a throw on
// `input.orderId` would be tried again for hours.
if (!input?.orderId) return { fail: "a refund run needs an order" };
switch (step) {
case "start": {
// However often this step runs, the row is written once.
await env.DB
.prepare("insert into refunds (order_id, payment_intent, amount, requested_by, status) values (?, ?, ?, ?, 'asked') on conflict (order_id) do nothing")
.bind(input.orderId, input.paymentIntent, input.amount, input.requestedBy)
.run();
const about = { orderId: input.orderId, amount: input.amount };
if (input.amount <= 50000) return { next: "issue", state: about };
// A step arrives at the app's own address (a preview's run, at the preview's), so the link opens the right page.
const page = new URL(`/approve/${input.orderId}`, request.url);
const mailed = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { "content-type": "application/json", "Idempotency-Key": `refund-${input.orderId}-ask` },
body: JSON.stringify({
from: "refunds@acme.test",
to: MANAGERS,
subject: `Refund of $${input.amount / 100} for order ${input.orderId}`,
text: `${input.requestedBy} asks for a refund of $${input.amount / 100}. Approve or reject it here: ${page}`,
}),
});
if (!mailed.ok) throw new Error(`the mail provider answered ${mailed.status}`);
return { next: "decide", state: about, wait: { event: "decision", timeout: "3d", otherwise: "expire" } };
}
case "decide": {
// The manager's answer, with a `from` wirl wrote: who sent it, never what the page claimed.
const by = event.from.email;
// Only a person who is a manager, and not the one asking, decides, and only for the amount this run is about.
if (by === null || !MANAGERS.includes(by) || by === input.requestedBy || event.amount !== state.amount) {
await mark(env, state.orderId, "needs_review", by);
return { done: true, output: { refunded: false } };
}
if (event.approve !== true) {
await mark(env, state.orderId, "rejected", by);
return { done: true, output: { refunded: false } };
}
await mark(env, state.orderId, "approved", by);
return { next: "issue", state };
}
case "issue": {
const refund = await refundOf(env, state.orderId);
const answer = await fetch("https://api.stripe.com/v1/refunds", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", "Idempotency-Key": `refund-${state.orderId}` },
body: new URLSearchParams({ payment_intent: refund.payment_intent, amount: String(state.amount) }),
});
if (answer.status >= 500) throw new Error(`Stripe answered ${answer.status}`);
if (!answer.ok) return { fail: `Stripe refused the refund: ${answer.status}` };
await mark(env, state.orderId, "issued", null);
return { done: true, output: { refunded: true } };
}
case "expire":
await mark(env, state.orderId, "expired", null);
return { done: true, output: { refunded: false } };
case "failed":
case "cancelled":
await mark(env, input.orderId, step, null);
return {};
default:
return { fail: `the refund flow has no step ${step}` };
}
}
The approval page. The manager opens the link signed in, so each manager must be able to open the app, and its preview to try the flow there: share it with them (wirl share dana@acme.test), or make it visible to the org (see Sharing). The app shows the request and how it stands, from its row, and, while the row says asked, to a manager who is not the one asking, a form that carries the amount on the page:
<form method="post">
<input type="hidden" name="amount" value="80000">
<textarea name="note"></textarea>
<button name="approve" value="yes">Approve $800.00</button>
<button name="approve" value="no">Reject</button>
</form>
The form posts back to the app, which checks that its own page sent the form and who is asking, then sends the decision to the run, by its key. It answers with a redirect back to the page however the send went, and gives a post another page sent the same redirect, sending nothing. Someone who is not a manager, or is the one asking, is answered 404, as is an order with no row:
async function answerRefund(request, env, order) {
if (!fromOwnPage(request)) return Response.redirect(request.url, 303);
const email = emailOf(request);
const refund = await refundOf(env, order);
if (!refund || !MANAGERS.includes(email) || email === refund.requested_by) return new Response("Not found", { status: 404 });
const form = await request.formData();
const sent = await fetch(`https://flows.wirl/runs/refund-${order}/events/decision`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ approve: form.get("approve") === "yes", note: String(form.get("note") ?? ""), amount: Number(form.get("amount")) }),
});
// The row says a decision was sent until the run marks it, a moment later, so the page shows no form meanwhile.
if (sent.status === 202) await env.DB.prepare("update refunds set status = 'sent' where order_id = ? and status = 'asked'").bind(order).run();
return Response.redirect(request.url, 303);
}
What each path does:
- Approve. The wait takes the event, and
decideruns with it asevent: the page'sapprove,noteandamount, and afromwirl wrote.decidechecks who sent it and that the amount is the one this run is about, andissuerefunds once, underIdempotency-Key: refund-41, however many times it runs while Stripe keeps the key, about a day (see The vendor that is down). A failed run can be retried for longer than that, so look at the payment in Stripe before retrying one that failed atissue. - Reject.
decidemarks the rowrejected, and the run ends with no refund. An answer that fails the checks is neither: the row saysneeds_review, and a person looks. - Nobody decides. After three days
expireruns instead ofdecide, and nothing is refunded. - A late click. Once the run has stopped waiting, a send answers
409with"error": "not_waiting"(or404once the run has ended), and the manager is sent back to the page, which shows the refund as its row has it,expiredonceexpirehas run. A wait stops taking sends 5 seconds before its timeout, so a send that late is answerednot_waitingtoo. An event sent before its wait opens is refused the same way, never kept for later. - A double click. The first send is taken (
202); the second answers409with"error": "already_sent". Only the first answer to a wait counts. - Who approved.
event.fromis wirl's record of who sent the event, set from who wirl let in, never from the body:{ "via": "visitor", "userId": "…", "email": "dana@acme.test", "session": true }. Itsemailisnullwhen no person sent it, as from a public path, a schedule or another flow's step, and such an event is never an approval.wirl runs show, the run's page and the audit log name the same person. - A person answers a live run only from a browser session, never with a token. A send whose sign-in was made with an API token is refused with
403and"error": "person_only", so an agent or script holding a token cannot answer as a person. A send that names nobody (a webhook on a public path, a schedule, another flow's step) still reaches a live run, withfrom.emailnull, and anyone who may deploy the app can answer a waiting run on its page in the dashboard: sodecidetakes a decision only from a named manager, checkingfromrather than trusting whoever answered. A post another page sends through the manager's browser cannot answer one as the manager either (Posts from another page, below). On a preview, anyone who can reach the run may answer it, with a token too, which is how you try the flow (below).
Other refusals a send can meet: 400 for a body that is not a JSON object, 413 for one over 64 KiB, 429 for more than 10 sends a second to one wait, and 409 with "error": "too_many_pending" when the run already holds 10 events not yet delivered.
Posts from another page#
Another page, another app's on wirl.run included, can make a signed-in person's browser post the request form or the approval form, and the browser says which page sent a post in its sec-fetch-site and origin headers. wirl reads them before the app does and tells such a post from the person's own by them, the rule fromOwnPage follows, in a browser that sends either header: an app that is not public never sees such a post, outside its public paths, since wirl answers it with sign-in, as if the person were signed out, and a public app gets it signed out, with the identity headers empty. So a post that another page sent through the manager's browser cannot answer a live run as the manager: a send made for it would reach the run with a from that names nobody, which is never an approval. Sharing has the rule, and what it leaves to any app.
Two things are left to the refund's pages, and the code above does both:
- Check
fromOwnPageall the same. On a public app the same post still arrives, signed out, and could start a run that names nobody as asking or change the app's own rows. A request from outside a browser sends neither header and passes the check, so the request form also starts nothing unless someone is signed in: a refund of $500 or less waits for no decision. - Answer another page's post with a redirect, never a page. A browser can send a post again when the person reloads the page the post was answered with, and Chromium sends another page's post again as
same-origin, with the app's ownorigin, when that page reloads itself from script, which no check of the headers, wirl's included, can tell from the manager's own: sent again, a post a public app got signed out arrives signed in. After a redirect, a reload asks for the page and sends nothing.
The vendor that is down#
If the vendor's API is down, try that step again tomorrow.
A throw is for blips: with the default five retries, a step that keeps throwing fails the run a little over seven hours later. For "tomorrow", answer retry:
case "send": {
const order = await env.DB.prepare("select * from orders where id = ?").bind(input.orderId).first();
// Before the `try`, whose `catch` would take a missing order for a vendor that is down.
if (order === null) return { fail: `there is no order ${input.orderId}` };
let answer = null;
try {
answer = await fetch("https://api.vendor.example/orders", {
method: "POST",
headers: { "content-type": "application/json", "Idempotency-Key": `order-${order.id}` },
body: JSON.stringify({ sku: order.sku, quantity: order.quantity }),
});
} catch {
// The vendor did not answer at all: treated as down, below.
}
if (answer === null || answer.status >= 500) {
const days = (state?.days ?? 0) + 1;
if (days > 7) return { fail: "the vendor was down for a week" };
return { retry: "1d", state: { days } };
}
if (!answer.ok) return { fail: `the vendor refused the order: ${answer.status}` };
await env.DB.prepare("update orders set status = 'sent' where id = ?").bind(order.id).run();
return { done: true, output: { sent: true } };
}
retryruns the same step again after the delay, with the samen, so the samex-wirl-idempotency-key, andattemptone more. Every try sends the vendor the sameIdempotency-Key, named for the order, so an order the vendor took on a try whose answer was lost is not placed twice. That holds only while the vendor keeps the key. Many keep one for about a day (Stripe, for one), which is when aretry: "1d"comes back. Where the vendor forgets keys sooner than your delay, send the order a reference of your own and, on a later try, ask the vendor for an order with that reference before placing it again.- Send
statewith everyretry: it is all the next try gets. Here it counts the days. - Answering
503with aRetry-Afterof your own is a blip too: the step is tried again after that delay, up to 6 hours, and it spends one of the flow'sretries.
Importing 5,000 rows#
Import these 5,000 rows a few hundred at a time.
The rows go into env.DB, in a table keyed on each row's own id, which the upsert's on conflict (id) needs, made before the router runs as the refunds table is. state carries only a cursor:
const SCHEMA = ["create table if not exists customers (id text primary key, name text, email text)"];
Here the rows come from a vendor that hands them out a page at a time; a file read a slice at a time works the same way:
case "start":
case "page": {
const url = new URL("https://api.vendor.example/rows?limit=250");
if (state?.cursor) url.searchParams.set("after", state.cursor);
const answer = await fetch(url);
if (!answer.ok) throw new Error(`the vendor answered ${answer.status}`);
const { rows, next } = await answer.json();
if (rows.length > 0) {
await env.DB.batch(rows.map((row) =>
env.DB
.prepare("insert into customers (id, name, email) values (?, ?, ?) on conflict (id) do update set name = excluded.name, email = excluded.email")
.bind(row.id, row.name, row.email)));
}
const imported = (state?.imported ?? 0) + rows.length;
if (next === null) return { done: true, output: { imported } };
return { next: "page", state: { cursor: next, imported } };
}
- That is about twenty steps for 5,000 rows, each answer a few bytes. Carried in
stateinstead, the rows would make every answer bigger than the one before, and a larger import, or one with wider rows, would pass 1 MiB and fail the run. - The upsert makes a step that runs twice harmless: a page lands once however often it is written. With a plain
insert, a step run twice would add its page twice, or fail on the key. - A page the vendor fails to serve is a blip: the step throws, and the next try starts from the same cursor.
- A schedule can start the import every night: see Schedules.
An agent that works for an hour#
Let the agent keep working on this for an hour.
One turn of the model is one step. The conversation lives in env.DB, in a table keyed by the run and the step's n, which the upsert's on conflict (run, n) needs, and state holds only when to stop. The task, in the run's input, asks the model to end its last reply with [finished]:
const SCHEMA = ["create table if not exists turns (run text not null, n integer not null, reply text not null, primary key (run, n))"];
async function agentStep({ run, step, n, input, state }, env) {
switch (step) {
case "start":
return { next: "turn", state: { until: Date.now() + 60 * 60 * 1000 } };
case "turn": {
const kept = () => env.DB.prepare("select reply from turns where run = ? and n = ?").bind(run, n).first("reply");
// A step that runs again finds the reply it kept, and neither asks the model nor acts on it twice.
let reply = await kept();
if (reply === null) {
// The last ten replies go back to the model, so however long the hour runs, the conversation stays inside its context.
const { results } = await env.DB
.prepare("select reply from (select n, reply from turns where run = ? order by n desc limit 10) order by n")
.bind(run)
.all();
const messages = [{ role: "user", content: input.task }];
for (const turn of results) messages.push({ role: "assistant", content: turn.reply }, { role: "user", content: "Go on." });
await env.DB
.prepare("insert into turns (run, n, reply) values (?, ?, ?) on conflict (run, n) do nothing")
.bind(run, n, await askModel(messages))
.run();
// Two tries of one step can race; both go on from the reply that was kept.
reply = await kept();
}
if (reply.includes("[finished]") || Date.now() > state.until) return { done: true, output: { turns: n } };
return { next: "turn", state };
}
case "failed":
case "cancelled":
return {};
default:
return { fail: `the agent flow has no step ${step}` };
}
}
The model is asked for a stream, since a vendor has 60 seconds to answer whole and a long turn takes longer. A streamed answer has 60 seconds to start and 13 minutes for the rest, so the flow gives each step the most it may. The model's key is a connection of kind http_header: on the app's keys page the person gives the header's name, x-api-key for Anthropic, and the key, and wirl adds it to each call on the way out, so askModel never holds it (see Connections):
{
"app": "agent",
"server": "server.js",
"connections": [{ "slug": "anthropic", "hosts": ["api.anthropic.com"], "kind": "http_header" }],
"flows": [{ "name": "agent", "step_timeout": "15m" }]
}
The step reads the stream to the end and checks it finished, since a cut stream can look like an ordinary end:
const MODEL = "<a model name from the vendor's documentation>";
async function askModel(messages) {
const answer = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, stream: true }),
});
if (!answer.ok) throw new Error(`the model answered ${answer.status}`);
const decoder = new TextDecoder();
let text = "";
let pending = "";
let finished = false;
for await (const chunk of answer.body) {
pending += decoder.decode(chunk, { stream: true });
const lines = pending.split("\n");
pending = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.type === "content_block_delta" && data.delta.type === "text_delta") text += data.delta.text;
if (data.type === "message_stop") finished = true;
}
}
if (!finished) throw new Error("the model's stream ended before its last event");
return text;
}
- An hour is a few hundred steps, far below the 9,900 a run may take.
- Only the last ten replies go back to the model. Sent whole, a few hundred turns can pass the model's context, and every turn after that fails as a blip until the run fails. Where a turn needs more than the last few, keep a running summary in
env.DBand send it with them. - A reply that asks for a tool with an effect (a message sent, a row written) is acted on in the same step, keyed the same way: by the run and
n, or by anIdempotency-Keynamed for the effect. - A stream that ends early is a blip: the step throws, and the turn is asked again.
Trying a flow#
wirl dev does not run flows. It prints a curl for each flow that posts a first step to your module, so you can check one step's answer by hand, and https://flows.wirl answers there with 404 and "error": "no_flows". The curl posts input as null, which is what a run started with no input gets, so the refund answers it fail, for want of an order: put the run's input in the curl's -d to try a step that reads it. Flows run for real in a preview, as the live app's do:
wirl preview --name try-refund
wirl flows start refund --preview try-refund --fast --key refund-41 \
--input '{"orderId":"41","paymentIntent":"pi_test","amount":80000,"requestedBy":"sam@acme.test"}'
wirl runs show refund-41 --preview try-refund
wirl runs send refund-41 decision --preview try-refund --data '{"approve":true,"note":"ok","amount":80000}'
wirl logs --run <the run's id> --preview try-refund
--fast, for a preview's run only, makes every sleep,untilandretrydelay 5 seconds, every wait's timeout 60 seconds, and every rung of the retry ladder 10 seconds. The 90 seconds after a try that ran out of time stays. So the refund's three-day wait times out in a minute, andexpireruns: under--fast, a send has less than 60 seconds from when the wait opens. The refund has no sleep for--fastto shorten, so to try Approve at your own pace, start the run without it.wirl runs sendanswers a preview's run, from a token as well as a person, since a preview is you trying things. It never answers a live run.- A send answers as you, from
wirl runs sendandwirl_send_eventalike: the event'sfrom.emailis your own address, as is thex-wirl-user-emaila page gets fromwirl fetchorwirl_preview_fetch. The refund takes a decision only from a manager who is not the one asking: unless your address is inMANAGERS, the send above ends the runneeds_review, with nothing refunded, and a decision you post on the approval page is answered404by its own check. That is the check doing its job, so keep it. To walk Approve, put your own address inMANAGERSin the build you preview (and take it out beforewirl deploy), withrequestedByin--inputsomeone else's, assam@acme.testis here; or have a manager on the list open the approval link, signed in. - A preview calls the app's real connections with their real keys (see Previews), so try a flow that pays or mails someone against the vendor's test mode, or a stand-in.
- A preview's own pages start and answer the preview's runs through
https://flows.wirlas the live app's do, and never reach a live run. A run a page starts is never fast. - A preview's run stays on the build it started on. Deploying the preview again stops its unfinished runs, and so does removing it or letting it expire. Its keys stay taken all the same: a key names its run until 30 days after the run ends, on the preview's next build, and on a preview made again under the same name. Run the commands above a second time and
--key refund-41finds the first run, whatever became of it, and starts nothing. Give each try a key of its own, here with a new order:--key refund-42, with"orderId":"42"in--input. - An agent does the same with
wirl_preview, thenwirl_start_flowwithpreviewandfast, andwirl_show_run,wirl_send_eventandwirl_logswith the run's id: each takespreviewtoo, since a preview's run is found, answered and logged only on its preview. - An app's previews together have pools of their own, apart from the live runs': 10,000 steps a day, 10 steps at once and 1,000 unfinished runs, against the live runs' 50,000, 50 and 10,000. A
--fastloop, or a loop that never sleeps, can use them up, and never touches what the live app has.
Seeing runs#
wirl flowslists an app's flows, each with its step timeout, retries, unfinished runs and last run.wirl runs [<flow>]lists its runs, newest first, and--statusnarrows them.wirl runs show <run or key>prints a run's status and where it is (waiting for which event until when, or sleeping until when), who started it, its output or its error, its page on the dashboard, the newest 50 of its tries, each with the version of the app that answered it, with up to 50 earlier tries that went wrong before them, and the newest 50 of its events, each with who sent it and whether it reached the run. It says how many it leaves out, and the run's page andwirl runs show --jsonhave every one.- On the dashboard, the app's Overview has a Runs card for people who may deploy it: the newest ten runs, a form to start one, and each run's own page, with Cancel and Retry. A person signed in on a waiting run's page can also answer its wait there.
- From the app itself,
GET https://flows.wirl/runs/<run or key>answers{ status, step, waiting, output, outputTruncated, error, pausedUntil }, for a page that shows how a request is going.waitingis{ event, until }while a wait is open.pausedUntilis when the pause holding the run for the day ends, andnullwhile none does. Anulloutput withoutputTruncated: truewas over 64 KiB, and wirl kept none of it. wirl logs --run <run>prints what the run's steps printed in the last hour, or as far back as--sincesays, up to the seven days logs are kept, with--preview <name>for a preview's run.
A run is starting, running, sleeping, waiting, done, failed, cancelled, or unknown while Cloudflare cannot be asked about it. A paused run keeps its status, and says until when: wirl runs show adds a Paused: line under it (see Limits), its page says the same, and the read above gives the time as pausedUntil. A run Cloudflare no longer has is not left unknown: it ends failed, with the error "Cloudflare no longer has this run, so wirl cannot tell how it ended. Start a new run instead.", and cannot be retried.
Starting a run from wirl's own surfaces (wirl flows start, wirl_start_flow, the Overview's form), and cancelling or retrying one, take a person or a token that may deploy the app, as wirl run does. A schedule can start one too: see Schedules.
Retrying a failed run#
wirl runs retry <run or key> (or Retry on the run's page, or wirl_retry_run) runs a failed run again from the step it failed at, or with --from <n> from an earlier step. The steps before that are not called again; each step from there runs again under the same n and the same idempotency key, which is why a step must be safe to run twice. Retried from a step that opened a wait, the run waits again and takes a new answer.
A retry works from 4 minutes after the run failed until 3 days after, which is how long wirl has Cloudflare keep a failed run; after that, start a new run. The 4 minutes are there so that nothing the failed go sent can still land in the new one. A run that ended failed because Cloudflare no longer has it (above) cannot be retried at all, and its page offers no Retry. A retried run numbers its steps again from where it starts, so each try is marked with the retry it belongs to: once a run has been retried, wirl runs show adds a gen column, 0 for the run's first go and 1 after its first retry, and the run's page tags the same tries retry 1.
Cancelling a run#
wirl runs cancel <run or key> (or Cancel on the run's page, or wirl_cancel_run) stops a run. A step already running is stopped where it is, and your module gets one cancelled call, with 30 seconds to mark its rows.
Deleting an app cancels its unfinished runs, and removing a preview, deploying it again or letting it expire cancels the preview's. These are carried out in the background, and your module is not called, since its code is going; deleting an app says how many runs it cancelled. Restoring a deleted app does not bring its runs back.
When a run fails#
Your module gets the failed call once, with the reason and the state the failing step was given; a run that ended failed because Cloudflare no longer has it gets no failed call: it comes from the run itself, and the run is gone. The run's page and wirl runs show say why it failed. For a live run, the app's owner is emailed, at most once a day for each flow, with a link to the run's page; the mail carries nothing your app wrote.
Deploys and rollbacks#
A deploy between two steps runs the next step on the new code, with the state the old code wrote. wirl deploy says how many runs of each flow are waiting and will resume on the new version, and, when a deploy no longer declares a flow, how many of its runs will fail at their next step, with no failed call, since no code answers the flow any more.
A rollback, by hand or automatic, can hand a newer version's state and step names to older code that was never written to read them. So it lists the runs whose last step the version rolled away from answered, oldest first, with the count of all: up to 100 of them in the rollback's answer, and fewer on the Overview, each linked to its page. Look at those.
Limits#
The ones a flow meets first. Limits has every figure.
- A step has its
step_timeout, 30 seconds to 15 minutes. An answer stays under 1 MiB, and a run's input, an event's body and the output wirl keeps are each at most 64 KiB. - A run takes at most 9,900 steps, where Cloudflare's default is 10,000. Every try of a step counts, and so does every wait; a sleep does not.
- An app's live runs take at most 50 steps at once. The next waits its turn, trying again after 15 seconds and then twice as long each time, up to 10 minutes, each wait up to a tenth longer, and each of those waits counts as a step of the run and toward the day's steps.
- An app's live runs take at most 50,000 steps a day. Past it they pause, never fail, until 00:00 UTC, and the app's owner is told once. A paused run keeps its status, and
wirl runs showsays why under it:Paused: the app's live runs used today's 50,000 flow steps, so this run's next step waits until <the next 00:00 UTC>. - An app's previews together have pools of their own, apart from the live runs': 10,000 steps a day, 10 steps at once and 1,000 unfinished runs, against the live runs' 50,000, 50 and 10,000.
- An org holds at most 50,000 unfinished runs, live and preview together.