GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/server

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
npm install @gnldev/server @gnldev/durable hono
create config
import { 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).

app.ts
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 raporu

A 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 — run
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 — 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:

Express, Fastify, Koa, node:http, Nest
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
// 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#

fncreateRestApi

Generates a Hono router from a createGnl config: /agents/:name/run|resume|stream, /agents, /workflows/:name/run, /workflows, /runs, /runs/:id, /usage, /openapi.json.

typeRestApiOptions

Second argument to createRestApi: title (OpenAPI title), auth (optional AuthProvider/ReadWriteAuth), org (opt-in multi-org isolation), budgets (fallback budget/quota limits).

fnbuildOpenApi

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

fnpipeAgentStream

Pumps a streamDurable result (AI SDK StreamTextResult) into SSE: text-delta/tool-call/tool-result/error/interrupt/done events.

fninterruptsFromSteps

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

fntoNodeHandler

toNodeHandler(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.

fnstreamDurable

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

classRunUnfinishedError

The error behind run_unfinished: a model stream ended without its finish part and nothing on this side stopped it. Answered 502.

Tip
If you don't pass auth, all endpoints are OPEN (existing behavior is preserved). In production it's recommended to pass at least an auth via roleAuth (@gnldev/auth) — GET endpoints require read, POST endpoints require write permission.
Idempotency
Every run request requires 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).