Docs menu
Automatic REST API + OpenAPI + SSE
Turns a createGnl configuration into a durable HTTP API in one line: /agents/:name/run|resume|stream (SSE), /workflows, /runs, /usage, and a generated /openapi.json.
What it's for / when to use it#
If you already have a createGnl configuration (agents + workflows + storage), you don't need to hand-write endpoints to expose it as an HTTP API. createRestApi reads the config and automatically generates run, resume, SSE streaming, run history, and usage endpoints — since it all flows through runDurable, exactly-once and durability guarantees come for free.
Typical scenario: wire it into a Hono app with app.mount('/api', createRestApi(config, opts)) and point a client (web, CLI, another service) directly at these endpoints. Auth, multi-org isolation, and budget/quota are optional — if you don't pass them, the endpoints are open and single-org (as shown below).
Setup / import#
The package is @gnldev/server; for journal/storage it uses @gnldev/durable and its sub-export @gnldev/durable/sqlite.
npm install @gnldev/server @gnldev/durable honoimport { Hono } from 'hono';
import { createRestApi } from '@gnldev/server';
import { createGnl } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';
const storage = new SqliteStorage('runs.db');
const config = {
storage,
agents: {
starwars: { model: buildModel(), system: SYSTEM, tools: makeSwapiTools(), maxSteps: 4 },
},
};Step-by-step usage#
Once the config is ready, createRestApi(config, opts) returns a Hono router; mount it at any path you want (e.g. /api).
const app = new Hono();
app.mount('/api', createRestApi(config, { title: 'SWAPI Free', auth }));
// POST /api/agents/starwars/run {"runId":"demo-1","prompt":"..."}
// POST /api/agents/starwars/stream -> SSE (text-delta/tool-call/tool-result/interrupt/done)
// GET /api/usage -> token/maliyet raporuA run request requires runId — it's the idempotency key for the exactly-once guarantee; POSTing again with the same runId won't re-run tool calls that already completed.
curl -s -X POST http://localhost:3001/api/agents/starwars/run \
-H 'content-type: application/json' \
-d '{"runId":"demo-1","prompt":"Who is Luke Skywalker?"}'For streaming, POST the same body to /agents/:name/stream — the response is an SSE stream (text-delta, tool-call, tool-result, interrupt, done events). If a tool is suspended pending human approval (interrupt), continue it by POSTing { runId, approvals } to /agents/:name/resume with the same runId — the input (prompt/messages) is read from the journal, so you don't need to pass it again.
For a usage/cost report, use GET /usage:
curl -s http://localhost:3001/api/usage
# {"org":null,"usage":{...},"limit":null,"exceeded":false}The generated OpenAPI 3.1 schema is served at GET /openapi.json — run/resume/stream paths are automatically added for every registered agent and workflow; you can feed this directly into tools like Swagger/Redoc.
Node servers and Next.js#
createRestApi returns a fetch handler. On Node, bind it with toNodeHandler from @gnldev/server/node:
import { createServer } from 'node:http';
import { toNodeHandler } from '@gnldev/server/node';
express().use('/api', toNodeHandler(api)); // Express
await fastify.register(middie); fastify.use('/api', toNodeHandler(api)); // Fastify + @fastify/middie
koa.use(c2k((req, res, _next) => toNodeHandler(api)(req, res))); // Koa + koa-connect
createServer(toNodeHandler(api)).listen(3000); // node:http
nestApp.use('/api', toNodeHandler(api)); // Nest (Express or Fastify)Three rules, measured on live servers and held by a test that boots Express 5, Fastify 5 and Koa 3: bind at the middleware layer, not as a route (on Fastify a route runs after the JSON parser has drained the body, and the bridge answers 500 body_consumed_upstream); mount before whatever parses request bodies; and on Koa, declare the third parameter — koa-connect reads fn.length, and with two parameters Koa answers its own 404 first.
Next.js needs no bridge: a route handler takes a Request and returns a Response. One catch-all route hands the API every request under its folder, with the folder's prefix cut — no next.config entry needed:
// app/api/[...path]/route.ts
import { createRestApi } from '@gnldev/server';
import { PostgresStorage } from '@gnldev/durable/postgres';
const api = createRestApi({ storage: new PostgresStorage({ connectionString: process.env.DATABASE_URL }), agents: {} });
function handle(req: Request): Response | Promise<Response> {
const url = new URL(req.url);
url.pathname = url.pathname.replace(/^\/api(?=\/|$)/, '') || '/';
return api.fetch(new Request(url, req));
}
export { handle as GET, handle as POST, handle as PUT, handle as PATCH, handle as DELETE, handle as OPTIONS };Measured on Next.js 16.4 with next build and next start: the agent list, a run and a /stream read as server-sent events, on memory and on Postgres. That is the Node runtime, a route handler's default; the Edge runtime was not tried, and the stores need Node.
A cut model stream is not an answer#
A run's ending is read from what the model's stream says, never assumed. A stream that stops without its finish part, and without a cancel, a drain or a timeout having stopped it, ends the run failed with the code run_unfinished — not completed — and POST /agents/:name/run answers 502. On /agents/:name/stream the 200 is already sent, so the error event carries the code. A run an executor stops at close() ends released instead: another process continues it, and it did not fail.
It is a 502 and not a 400 because the request was fine: the cut happened upstream of the caller, and a client's retry logic reads a 400 as "never retry", which is exactly backwards. Re-drive the same runId. Nothing complete is recorded for the cut step, so the model is called again, and every step that completed replays from the journal; a step cut after a tool call replays its journaled call, so the tool runs once. A fresh runId would run everything again, including a tool call that already had an effect. The text that streamed before the cut is not lost: it is in the run's event log.
What counts as a cut: no finish part at all, or a stream that itself broke; a finishReason of interrupted (reported from @ai-sdk/openai 4.0.100 and @ai-sdk/anthropic 4.0.86); an ending of other with no provider reason and no usage on a model that is not v2; or an adapter's report that the stream ended without a finish reason (@ai-sdk/openai-compatible). That is the list of signals the engine reads, not a promise that every cut is caught: a v2 model's cut, when the adapter sends its default finish, and a cut after a proxy already sent usage on an adapter older than interrupted, read like an ending and answer 200. A v2 model behind wrapLanguageModel can read as cut when it is not; use the model unwrapped, or have it report usage.
The floors moved with it: ai 7.0.149, @ai-sdk/openai 4.0.100, @ai-sdk/anthropic 4.0.86 and @ai-sdk/provider 4.0.32 are the first versions that report a cut stream as interrupted. An OpenAI-compatible endpoint that ends every stream with no finish_reason now answers run_unfinished on each attempt, where it answered 200 — a retry gets the same body, so fix the server or the gateway in front of it. The full entry, with what to check when it repeats, is docs/errors/run_unfinished.md.
API reference#
createRestApiGenerates a Hono router from a createGnl config: /agents/:name/run|resume|stream, /agents, /workflows/:name/run, /workflows, /runs, /runs/:id, /usage, /openapi.json.
RestApiOptionsSecond argument to createRestApi: title (OpenAPI title), auth (optional AuthProvider/ReadWriteAuth), org (opt-in multi-org isolation), budgets (fallback budget/quota limits).
buildOpenApiGenerates an OpenAPI 3.1 schema from the list of agent and workflow names; createRestApi calls this automatically at the /openapi.json endpoint, and it can also be imported directly.
pipeAgentStreamPumps a streamDurable result (AI SDK StreamTextResult) into SSE: text-delta/tool-call/tool-result/error/interrupt/done events.
interruptsFromStepsExtracts suspended tool calls (Interrupt[]) from a list of completed steps — the same logic as the suspend handling in runDurable; pipeAgentStream uses this for the interrupt event at the end of the stream.
toNodeHandlertoNodeHandler(handler) — (req, res) for node:http and every framework built on it (Express, Fastify via @fastify/middie, Koa via koa-connect, Nest). Answers 500 body_consumed_upstream when something read the body first.
streamDurableComes from @gnldev/durable (used indirectly inside createRestApi.gnl.stream()): a durable + streaming run — the same exactly-once/suspend guarantees, with the result streamed as fullStream.
RunUnfinishedErrorThe error behind run_unfinished: a model stream ended without its finish part and nothing on this side stopped it. Answered 502.
auth via roleAuth (@gnldev/auth) — GET endpoints require read, POST endpoints require write permission.runId — this is the key to the exactly-once guarantee. Retrying with the same runId is NOT a new job — it counts as a continuation/retry of the existing one (completed tools won't re-run; if a job is suspended, the budget gate is skipped too).