Skip to content

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.

Terminal window
npm install -g @uraiai/uraijs-cli
uraijs --version

Binaries for macOS and Linux are also on the releases page.

Terminal window
uraijs init acme-reconcile --type workflow
cd 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.

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.

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;
}
@PersistentObject
export 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[] });
}
}
@PersistentObject
export 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);
}
}
@PersistentObject
export 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 });
}
}
@PersistentObject
export 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 });
}
}
@PersistentObject
export 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.

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.
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/model form.
  • The tools array 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.

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.

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.

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.

Terminal window
uraijs workflow run ReconcileInvoices -a '{"org_id":"org_123"}'
uraijs workflow list

The 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:

Terminal window
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"
}
Terminal window
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.

Deployment is the same push as a library.

  1. 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>.

  2. Copy the remote URL and push to it:

    Terminal window
    git remote add urai https://<studio-host>/git/<your-org-specifier>/tools/acme-reconcile
    git 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.