Write a workflow
This page builds one workflow and keeps it for the rest of the section. It reconciles overdue invoices in the Acme billing system: it reads the overdue list, decides what to do with each invoice, sends reminders, and asks a person before it writes anything off.
Install the CLI
Section titled “Install the CLI”npm install -g @uraiai/uraijs-cliuraijs --versionBinaries for macOS and Linux are also on the releases page.
Scaffold the project
Section titled “Scaffold the project”uraijs init acme-reconcile --type workflowcd acme-reconcile| File | What it is |
|---|---|
index.ts |
Your workflow and its steps. Edit this one. |
urai.toml |
Project manifest: name, type, entry point, local library overrides. |
urai.d.ts, tsconfig.json |
Editor types for the runtime globals and the urai:* imports. |
AGENTS.md |
Runtime rules, written for coding agents working in this repository. |
uraijs init also runs git init, because deployment is a push.
Two rules
Section titled “Two rules”Read these before you write any code. Everything else on this page follows from them.
Keep the coordinator deterministic. The run() method runs again from the
top on every tick. Put every fetch, model call, clock read, and random value
inside a step. A fetch in the coordinator fires again on every tick.
Never catch a sentinel. runStep(), runStepMany(), awaitApproval(), and
complete() signal by throwing a value that the host must see. A try/catch
around one of them breaks the run. Put the try/catch inside the step.
The code
Section titled “The code”import { PersistentObject } from "urai:persistence";import { Workflow, Step } from "urai:workers";import { secrets } from "urai:secrets";import { extract } from "urai:chat";
interface Invoice { number: string; customer_email: string; amount_cents: number; days_overdue: number;}
interface Assessment { action: "chase" | "write-off"; reason: string;}
@PersistentObjectexport class FetchOverdue extends Step { async run(args: any) { const key = await secrets.get("ACME_API_KEY"); const res = await fetch( `https://api.acme.com/orgs/${args.org_id}/invoices?status=overdue`, { headers: { authorization: `Bearer ${key}` } }, ); if (!res.ok) { throw new Error(`Acme API ${res.status} on overdue invoices`); } const body = await res.json(); this.completeStep({ invoices: body.invoices as Invoice[] }); }}
@PersistentObjectexport class ClassifyInvoice extends Step { async run(args: any) { const assessment = await extract<Assessment>({ model: "anthropic/claude-sonnet-5", name: "invoice_assessment", instructions: "You decide what to do with an overdue invoice. Choose 'write-off' " + "only when the balance is disputed or the customer is unreachable.", schema: { type: "object", properties: { action: { type: "string", enum: ["chase", "write-off"] }, reason: { type: "string" }, }, required: ["action", "reason"], }, input: JSON.stringify(args.invoice), }); this.completeStep(assessment); }}
@PersistentObjectexport class ChaseInvoice extends Step { async run(args: any) { const key = await secrets.get("ACME_API_KEY"); const res = await fetch("https://api.acme.com/reminders", { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json", "idempotency-key": `chase-${args.invoice_number}`, }, body: JSON.stringify({ invoice: args.invoice_number, to: args.email, }), }); if (!res.ok) { throw new Error(`Acme API ${res.status} on reminder`); } this.completeStep({ invoice: args.invoice_number, chased: true }); }}
@PersistentObjectexport class WriteOff extends Step { async run(args: any) { const key = await secrets.get("ACME_API_KEY"); const res = await fetch( `https://api.acme.com/invoices/${args.invoice_number}/write-off`, { method: "POST", headers: { authorization: `Bearer ${key}` } }, ); if (!res.ok) { throw new Error(`Acme API ${res.status} on write-off`); } this.completeStep({ invoice: args.invoice_number, written_off: true }); }}
@PersistentObjectexport class ReconcileInvoices extends Workflow { fetchOverdue: any; classify: any; chase: any; writeOff: any;
constructor(env: any) { super(env); this.fetchOverdue = this.registerStep("fetch-overdue", FetchOverdue); this.classify = this.registerStep("classify", ClassifyInvoice); this.chase = this.registerStep("chase", ChaseInvoice, { retries: 5, backoff: "exponential", delayMs: 1000, }); this.writeOff = this.registerStep("write-off", WriteOff); }
async run(args: any) { const { invoices } = this.runStep(this.fetchOverdue, { org_id: args.org_id, }).result as { invoices: Invoice[] };
if (invoices.length === 0) { this.complete({ chased: 0, written_off: [] }); return; }
const assessments = this.runStepMany( this.classify, invoices.map((invoice) => ({ invoice })), ).results as Assessment[];
const toChase = invoices.filter((_, i) => assessments[i].action === "chase"); const toWriteOff = invoices.filter( (_, i) => assessments[i].action === "write-off", );
if (toChase.length > 0) { this.runStepMany( this.chase, toChase.map((invoice) => ({ invoice_number: invoice.number, email: invoice.customer_email, })), ); }
if (toWriteOff.length === 0) { this.complete({ chased: toChase.length, written_off: [] }); return; }
// Stops here until a person decides. See /workflows/approvals/. const decision = this.awaitApproval("write-off", { invoices: toWriteOff.map((invoice) => invoice.number), total_cents: toWriteOff.reduce((sum, i) => sum + i.amount_cents, 0), });
if (!decision.approved) { this.complete({ chased: toChase.length, written_off: [], why: decision.comment, }); return; }
this.runStepMany( this.writeOff, toWriteOff.map((invoice) => ({ invoice_number: invoice.number })), );
this.complete({ chased: toChase.length, written_off: toWriteOff.map((invoice) => invoice.number), approved_by: decision.approver, }); }}Four things hold this together.
A step is a class. It extends Step, carries the @PersistentObject
decorator, does its work in run(), and reports the result with
completeStep(). A step that throws fails that attempt.
The coordinator registers its steps in the constructor. registerStep(name, StepClass) returns a handle. The name identifies the step in the run history.
The coordinator only routes. It reads memoized results, chooses branches,
and calls complete() at the end. Every branch above depends on a step result,
so every replay takes the same path.
Nothing carries an API key. secrets.get() resolves at the moment the step
runs, and extract() needs no key at all.
Read secrets with urai:secrets
Section titled “Read secrets with urai:secrets”import { secrets } from "urai:secrets";
const key = await secrets.get("ACME_API_KEY");secrets.get() is asynchronous and resolves through the host each time you call
it, so an expiring OAuth token comes back refreshed. That matters for a run that
waits a week on an approval and then calls your API.
Two rules apply:
- Never hardcode a secret. Never put one in the workflow arguments.
- The name must appear in the workflow’s allowed secrets, which
Register and secure it covers. A name that
is not allowed makes
secrets.get()reject, and that step fails.
Call a model with urai:chat
Section titled “Call a model with urai:chat”import { extract, chat } from "urai:chat";Use extract() when you want a structured object. It takes a JSON Schema and
returns a typed value. Use chat() when you want free-form text.
const answer = await chat({ model: "anthropic/claude-sonnet-5", messages: [{ role: "user", content: `Summarise the dispute on ${invoice}.` }], tools: ["web_search"],});Three rules apply:
- Pass no API key. The host mints a short-lived credential for the run. The workflow needs chat access switched on, or the call gets nothing.
- Always name the model in the
provider/modelform. - The
toolsarray names tools from your organization’s catalog. Tool calls run on the host until the model stops asking, so the reply is the finished answer. This is the same machinery as Call your UraiJS tools, and the same names work.
Both functions make a network call, so use them inside a step only.
Retry a step that fails
Section titled “Retry a step that fails”A step that throws fails the run, unless you declare a retry policy.
this.chase = this.registerStep("chase", ChaseInvoice, { retries: 5, backoff: "exponential", delayMs: 1000,});| Option | Default | Effect |
|---|---|---|
retries |
0 |
Attempts after the first one. |
backoff |
"fixed" |
"fixed", "linear", or "exponential". |
delayMs |
1000 |
The base wait. Every strategy waits this long before the first retry. |
Urai parks the failed step with a due time and runs it again when the wait ends. Nothing is held open during the wait, and the attempt count shows in the run history. When the attempts are spent, the step fails and the run fails with it.
A retry runs the step again from the top. Keep a retryable step idempotent. The
ChaseInvoice step above sends an idempotency key, so a retry after a timeout
does not send a second reminder.
Do not write a retry loop inside a step. A loop holds a worker for the whole wait, and nothing outside the step can see it happening.
Run a step once per item
Section titled “Run a step once per item”runStepMany(step, argsList) runs the step once for each entry in the list, in
parallel. It returns { results } in the same order as the input, once every
call has finished.
const assessments = this.runStepMany( this.classify, invoices.map((invoice) => ({ invoice })),).results as Assessment[];Each pair of step and arguments is memoized on its own. If three calls out of ten finish and the run stops, the next tick dispatches the seven that remain.
What the sandbox gives you
Section titled “What the sandbox gives you”The runtime is V8, not Node. You get fetch, WebSocket, console, URL,
crypto, TextEncoder, timers, and modern JavaScript. There is no require,
no fs, no process, and no Buffer.
Imports work four ways: npm packages by bare specifier or the npm: prefix,
https:// URLs, your own libraries as urai:lib/<org>/<name>, and the urai:
modules used above. Choose npm packages that stay on web APIs, because a package
that reaches for a Node built-in fails at run time.
Test it on your machine
Section titled “Test it on your machine”uraijs workflow run ReconcileInvoices -a '{"org_id":"org_123"}'uraijs workflow listThe CLI drives the same tick loop as the server. State persists to
.urai/workflows.json, and workflow run prints the instance id. Resume an
interrupted instance with that id:
uraijs workflow run ReconcileInvoices --id 8f2c...Finished steps are memoized, so a resume does not run them again.
urai:chat cannot do its token exchange from the CLI, because the exchange
needs a live host and an active run. Put an ordinary API key in a local secrets
file instead, and the same code runs unchanged:
{ "ACME_API_KEY": "test-key", "URAI_CHAT_API_KEY": "sk-urai-...", "URAI_BASE_URL": "https://chat.app.urai.dev"}uraijs workflow run ReconcileInvoices --secrets-file ./secrets.json -a '{"org_id":"org_123"}'The key has no effect in production. A workflow there gets its credential from the host.
Deploy it
Section titled “Deploy it”Deployment is the same push as a library.
-
In the developer studio for your organization, open Tools and create one with the name in
urai.toml. This creates a git repository at/git/<your-org-specifier>/tools/<name>. -
Copy the remote URL and push to it:
Terminal window git remote add urai https://<studio-host>/git/<your-org-specifier>/tools/acme-reconcilegit add .git commit -m "Reconcile overdue invoices"git push urai main
Use any username and a git access token as the password. Generate tokens under Settings > Git in the studio. See publish a library for the same flow in more detail.
The type = "workflow" line in urai.toml is what makes the project appear as
a workflow definition. A project of another type does not show up when you
register a workflow.
Pushed code is not runnable yet. Next: register it and set its envelope.