Quick Start
Start a workflow that waits for a payment, crash your app while it waits, and see the run finish.
This example is a workflow for a subscription app: when a user signs up, it gives them a 14-day free trial, then waits for their payment. While the workflow waits, it holds no worker, no thread and no database connection. You'll crash your app in the middle of the wait, start it again, then send the payment, and the run continues from where it stopped.
The Aiki server runs inside your app, so there is no separate Aiki server to run.
Prerequisites
You need Node.js 22.16+ or Bun 1.4+, and a database: SQLite, which needs nothing installed, or a PostgreSQL server.
Create a project:
mkdir aiki-quickstart && cd aiki-quickstart
npm init -y
npm pkg set type=module
npm install --save-dev tsxThe last line installs tsx, which runs TypeScript files on Node.
mkdir aiki-quickstart && cd aiki-quickstart
bun init -yInstall the SDK and apply the database migrations. Installation covers both in full.
npm install @aikirun/workflow @aikirun/client @aikirun/worker @aikirun/server @aikirun/memory srvxSQLite needs no driver: Aiki uses the SQLite built into Node.js and Bun. srvx serves the Aiki server over HTTP.
DATABASE_PROVIDER=sqlite DATABASE_PATH=./aiki.db \
npx aiki-server migrate applyOn Node 22 and 24, this command and your app print ExperimentalWarning: SQLite is an experimental feature and might change at any time. It is Node's notice about its built-in SQLite.
DATABASE_PROVIDER=sqlite DATABASE_PATH=./aiki.db \
bunx aiki-server migrate applynpm install @aikirun/workflow @aikirun/client @aikirun/worker @aikirun/server @aikirun/memory postgres srvxpostgres is the driver for Postgres, and srvx serves the Aiki server over HTTP.
DATABASE_PROVIDER=pg DATABASE_URL=postgresql://user:password@your-db-host:5432/aiki \
npx aiki-server migrate applyDATABASE_PROVIDER=pg DATABASE_URL=postgresql://user:password@your-db-host:5432/aiki \
bunx aiki-server migrate apply1. The workflow
This is the part you write for your business: two tasks and a workflow that ties them together. Save it as workflow.ts:
import { event, task, workflow } from "@aikirun/workflow";
const activateTrial = task({
name: "activate-trial",
async handler(userId: string) {
console.log(`Activated 14-day trial for ${userId}`);
},
});
const downgradeToFree = task({
name: "downgrade-to-free",
async handler(userId: string) {
console.log(`Downgraded ${userId} to the free plan`);
},
});
const sendReceipt = task({
name: "send-receipt",
async handler(userId: string) {
console.log(`Sent a receipt to ${userId}`);
},
});
export const trialV1 = workflow({ name: "subscription-trial" }).v("1.0.0", {
events: {
paymentReceived: event(),
},
async handler(run, input: { userId: string }) {
await activateTrial.start(run, input.userId);
// Wait until payment is received or the 14-day trial expires
const result = await run.events.paymentReceived.wait({ timeout: { days: 14 } });
if (result.timeout) {
await downgradeToFree.start(run, input.userId);
} else {
await sendReceipt.start(run, input.userId);
}
},
});activateTrial runs, then paymentReceived.wait() pauses the run until the payment arrives, and the user gets a receipt. If 14 days pass first, the user is downgraded instead.
2. The app
Your app runs the Aiki server and a worker. Save it as app.ts:
import { client } from "@aikirun/client";
import { inMemoryTimerPriorityQueue } from "@aikirun/memory";
import { database, server } from "@aikirun/server";
import { worker } from "@aikirun/worker";
import { serve } from "srvx";
import { trialV1 } from "./workflow";
const aikiServer = server({
db: database({ provider: "sqlite", path: "./aiki.db" }),
timerPriorityQueue: inMemoryTimerPriorityQueue(),
});
const runtimeHandle = aikiServer.runtime.start();
serve({ port: 9850, hostname: "127.0.0.1", fetch: aikiServer.handler });
const aikiClient = client({ handler: aikiServer.handler });
const workerHandle = worker({ workflows: [trialV1] }).start(aikiClient);
const shutdown = async () => {
await workerHandle.stop();
await runtimeHandle.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);import { client } from "@aikirun/client";
import { inMemoryTimerPriorityQueue } from "@aikirun/memory";
import { database, server } from "@aikirun/server";
import { worker } from "@aikirun/worker";
import { serve } from "srvx";
import { trialV1 } from "./workflow";
const aikiServer = server({
db: database({ provider: "pg", url: "postgresql://user:password@your-db-host:5432/aiki" }),
timerPriorityQueue: inMemoryTimerPriorityQueue(),
});
const runtimeHandle = aikiServer.runtime.start();
serve({ port: 9850, hostname: "127.0.0.1", fetch: aikiServer.handler });
const aikiClient = client({ handler: aikiServer.handler });
const workerHandle = worker({ workflows: [trialV1] }).start(aikiClient);
const shutdown = async () => {
await workerHandle.stop();
await runtimeHandle.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);runtime.start()runs the server's background loops, and the timer priority queue lets them wake a run the moment it's ready.serve(...)puts the server on http://localhost:9850, so the scripts in the next steps and the dashboard can reach it.hostname: "127.0.0.1"lets only programs on your machine connect, because the server in this guide runs without authentication.- The worker executes your workflows. Its client calls the server inside the same process, with no network hop.
- When you stop the app, for example with Ctrl+C,
shutdownstops the worker and the runtime before the app exits.
Start the app and leave it running:
npx tsx app.tsbun app.ts3. Open the dashboard (optional)
The dashboard is a web page that shows your runs. It reads them from the server your app serves on port 9850. With Docker, start it in a new terminal:
docker run --rm -p 127.0.0.1:9851:9851 \
-e AIKI_SERVER_UPSTREAM_URL=http://host.docker.internal:9850 \
ghcr.io/aikirun/dashboard:latestAIKI_SERVER_UPSTREAM_URL tells the dashboard where your app is. It can't be localhost: the dashboard runs inside a container, where localhost means the container itself. host.docker.internal is the name Docker gives your machine. -p 127.0.0.1:9851:9851 opens the dashboard on port 9851, to your machine only.
docker run --rm --network host \
-e AIKI_DASHBOARD_HOST=127.0.0.1 \
-e AIKI_SERVER_UPSTREAM_URL=http://127.0.0.1:9850 \
ghcr.io/aikirun/dashboard:latestAIKI_SERVER_UPSTREAM_URL tells the dashboard where your app is. On Linux a container has its own network, and your app only accepts connections from your machine. --network host runs the dashboard on your machine's network instead, and AIKI_DASHBOARD_HOST=127.0.0.1 opens it to your machine only.
Open http://localhost:9851. The list of runs is empty for now. Reload the page after each of the next steps to follow the run.
4. Start a trial
Other code reaches the server over HTTP. Save this as start.ts:
import { client } from "@aikirun/client";
import { trialV1 } from "./workflow";
const aikiClient = client({ url: "http://localhost:9850" });
const handle = await trialV1.start(aikiClient, { userId: "user-123" });
console.log(`Started run ${handle.run.id}`);Run it in another terminal:
npx tsx start.tsbun start.tsThe last line it prints is the run's id; keep it for step 6. In the app's terminal, you'll see the line activateTrial printed:
Activated 14-day trial for user-123The run is now waiting for the payment. In the dashboard, it shows as Awaiting Event.
5. Crash the app
Stop the app with Ctrl+C, or kill it outright, then start it again.
Activated 14-day trial doesn't print again: the trial isn't activated a second time. The run is still waiting for the payment, and the dashboard still shows it as Awaiting Event. The run lives in your database, not in your app's memory, so a crash or a restart doesn't lose it or start it over.
6. Send the payment
Save this as pay.ts:
import { client } from "@aikirun/client";
import { trialV1 } from "./workflow";
const runId = process.argv[2];
if (!runId) {
throw new Error("Pass the run id as the first argument");
}
const aikiClient = client({ url: "http://localhost:9850" });
await trialV1.events.paymentReceived.send(aikiClient, runId);
console.log(`Sent the payment to run ${runId}`);Run it with the id from step 4:
npx tsx pay.ts <run id>bun pay.ts <run id>The payment ends the wait, and the run completes. In the app's terminal, you'll see the line sendReceipt printed:
Sent a receipt to user-123The workflow continues from the line after the wait, as if the app had never gone down. The payment came before the 14 days were up, so downgradeToFree never runs. In the dashboard, the run now shows as Completed.
What you just used
- Task —
activateTrial/sendReceipt/downgradeToFree: a unit of work whose result is saved, so a restart never redoes it. - Workflow —
trialV1: runs tasks in order and waits for events, and every step is saved. - Event —
paymentReceived:wait()pauses the run with nothing held;send()or the 14-day timeout resumes it. - Server —
server({ db, timerPriorityQueue })+runtime.start(): keeps track of every run and wakes it when it's ready. - Client —
client({ handler })in the app,client({ url })in the scripts: the connection to the server. - Worker —
worker({...}).start(client): executes your workflows.
Next Steps
- Workflows — Deep dive into workflow concepts
- Determinism — Writing deterministic workflows
- Example workflows on GitHub — Runnable end-to-end examples