Build a durable agent
in an afternoon.
leftspin replaces the backend you'd otherwise build for an AI feature: session exchange, model calls, durable workflows, per-user memory, approvals, artifacts, metering and audit. This page is the whole happy path.
The local sandbox runs today. Hosted keys are going to design partners first, and the surface below may change before GA. We version breaking changes and tell you before they land.
Install
One package for sessions, runs, streaming, memory, approvals, artifacts, usage and the ledger. It works in Expo/React Native and any JavaScript runtime with fetch. The HTTP surface is documented for native clients.
npm i @leftspin/sdk
npx leftspin initor start the whole runtime locally — postgres, object storage, api, worker
npx leftspin sandbox # api :8080 · worker · postgres · minioExchange a session
A publishable key identifies the app, never the user. Your auth decides who the user is; leftspin mints a short-lived JWT scoped to that app and user. The secret key stays on your server and is never accepted from a client.
import { leftspin } from "@leftspin/sdk";
const app = leftspin({ publishableKey: "pk_test_…" });
// Your auth decides who the user is.
const session = await app.sessions.create({
userId: "u_2f81c",
ttl: "1h",
});
app.setSession(session.token); // held in memory, never on diskNever ship sk_ keys in an app bundle. The SDK refuses them outside a server environment, and the acceptance suite greps the client for key material.
Start a run
Runs are rows, not processes. Creating one returns immediately; the workflow executes server-side and journals every step. The client owns a stream, never the work.
const run = await app.runs.create({
workflow: "studio_job",
input: { prompt: "product shot, dusk", frames: 2 },
});
for await (const event of run.events()) {
if (event.type === "progress") setProgress(event.pct);
if (event.type === "artifact") setFrames((f) => [...f, event.url]);
if (event.type === "needs_input") setApproval(event.prompt);
if (event.type === "done") setStatus("done");
}Stream, then reconnect
Every event has a per-run sequence id, so a reconnect is a seek rather than a new stream. Pass after or Last-Event-ID and the server replays from that offset.
curl -N "$LEFTSPIN_URL/v1/runs/$RUN_ID/events" \
-H "Authorization: Bearer $USER_JWT" \
-H "Last-Event-ID: 128"Chat deltas are committed to the step result as well as streamed, so the journal and the stream always agree. A client dedupes on (seq, type) — it cannot double-apply a token.
Steps, approvals, effects
A workflow is a re-entrant function over the journal. On resume, completed steps return their recorded result and their bodies are skipped. Timers and approvals are rows, so waiting costs no worker.
export const studioJob = app.workflow("studio_job", async (ctx, input) => {
const plan = await ctx.step("plan", () => planRender(input));
const frames = await Promise.all(
plan.frames.map((frame, i) =>
ctx.step(`generate.${i}`, () => render(frame)),
),
);
const decision = await ctx.awaitApproval("publish", {
summary: `${frames.length} renders ready`,
timeout: "24h",
});
if (!decision.approved) return { cancelled: true };
return ctx.sideEffect("publish", `studio:${ctx.runId}`, () =>
publishAll(frames),
);
});| primitive | guarantee |
|---|---|
| ctx.step | Runs at most once per (run, name). Result committed first. |
| ctx.sleep | Timer is a row. A restart does not reset it. |
| ctx.awaitApproval | Suspends without a thread; resumes on a decided row. |
| ctx.sideEffect | One idempotency key per app. Applied once. |
| ctx.emit | Append-only event with a sequence offset. |
Prove isolation
Every read and write is scoped by (app_id, user_id) from the JWT, and Postgres FORCE ROW LEVEL SECURITY rejects rows outside the transaction's scope. The ledger is append-only and hash-chained; verification recomputes it from genesis.
GET /v1/ledger?verify=1
Authorization: Bearer $USER_JWT
{
"valid": true,
"checked": "all entries",
"entries": [
{ "seq": 1283, "action": "approval.granted" },
{ "seq": 1284, "action": "side_effect.applied" }
]
}A cross-tenant request returns 404, not 403 — the API does not leak whether the resource exists.
Emit a conversion signal
Runtime events come from workflow milestones and are emitted in the same transaction as the work. Clients can send completion events; both share a dedup key so ad platforms see one conversion, not two.
curl -X POST "$LEFTSPIN_URL/v1/events" \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "trial_started",
"dedup_key": "u_2f81c:trial:2026-11"
}'Delivery is an outbox: retries with backoff, dead-lettering, and a receipt per destination. Destinations include Meta CAPI, Google/Firebase, a generic webhook and a SKAN postback hook.
Errors
| status | code | what it means |
|---|---|---|
| 402 | quota_exceeded | Month-to-date spend or the per-user daily cap was hit. Render a paywall. |
| 404 | not_found | The resource is outside the caller's (app, user) scope. Existence is not leaked. |
| 409 | duplicate_signal | This dedup key was already recorded. Delivery is a no-op. |
| 429 | rate_limited | Per-app or per-user limit reached. Retry with backoff. |
| 503 | provider_unavailable | An upstream provider failed after retries. The run is requeued or quarantined, never half-applied. |
Hosted limits
- –The hosted runtime runs in a single region; BYOC and regional data planes are portfolio terms.
- –Push is an inbox + local notification in the sandbox; APNs/FCM delivery is wired on the hosted path.
- –LLM and media adapters ship for OpenRouter and fal; offline runs use a deterministic mock provider.
- –The dashboard UI is rolling out; the API, CLI and MCP surface are live today.
- –Token chunks are journaled per delta in the sandbox. v1 batches them and journals at step boundaries.
What leftspin is not
It runs your workflows. Your agent loop stays yours.
Attribution stays with your measurement stack.
No auth UI, no CRUD builder, no dashboard product.
Model spend passes through. We charge for the runtime.
Run it locally, then move to managed when the first user does.
