GNL
Tutorial

Build a durable AI agent

One project, from an empty folder to something you would actually deploy: an on-call agent that reads your runbooks, asks a human before it touches production, and cannot restart a service twice no matter how many times it is retried. Real model, real database, one package added per section — as the scenario earns it.

20 sectionsNVIDIA model · Postgres20 packages
Sections

What is GNL?

GNL is an open-source TypeScript framework for AI agents that are allowed to do things — restart a service, send an email, move a record — rather than only produce text.

It sits directly on the Vercel AI SDK: your tools are ordinary AI SDK tools and your model is an ordinary AI SDK model, so nothing you already have gets rewritten. What GNL adds is a journal — a durable record of every step a run took, which turns "this agent usually behaves" into guarantees you can name, test, and break on purpose.

What is durable execution?

A normal agent run lives entirely in memory. If the process dies halfway through — a deploy, an OOM, a dropped connection — everything it had done is gone, and the usual fix is to run it again from the top.

That is fine while the agent only produced text. It stops being fine the moment a step in the middle actually did something: running it again does not repeat a thought, it repeats an action.

Durable execution means each step is written down as it completes. A re-run reads that record and continues from where it stopped, so work that already happened is not done twice.

Why an AI agent needs it in particular

Because a model decides what to call, and models are not deterministic. The same request can produce the same tool call twice under a fresh call id — a documented AI SDK behaviour, not an exotic edge case. Retries, queue redeliveries and an operator clicking twice all land in the same place.

Durable execution is usually taught with a payment: don't charge the card twice. Easy to follow, and slightly dishonest — a double charge is reversible. You refund it and apologise.

So we will build something that is not. Our agent can restart a production service, and restarting one twice during an incident is a second outage on top of the one you were called for, with nothing to give back.

1Create the project

create-gnl@gnldev/cli

GNL ships a scaffolder, and it asks before it writes. One command gives you a complete, running project — gnl.config.ts, two agents, a side-effecting tool, and a test that proves the tool cannot run twice. No API key: the starter's model is a deterministic mock, so everything below happens on your machine.

npm create gnl@latest oncall-agent
cd oncall-agent
pnpm install

Four questions, and why only four

Pick Let me choose at the gate and you are asked four things. They are not preferences — each one is expensive to change later, which is exactly why they are asked now and why nothing else is. Features are added the day you need them (gnl add …); a project that skipped them lost nothing. --yes, or no terminal at all, takes the recommended answers and never blocks.

gnl init If the same work arrives twice, what should happen?
 Ask me someone is there to decide
 Refuse it this runs unattended
 Refuse it, and never let two copies race

gnl init Who does each run belong to?
gnl init Where should the record of every run be kept?
gnl init How will people reach this?

What you get

One folder per kind of thing, from the first file — so the answer to “where does the second agent go?” is never a decision you have to make. Folders appear as you need them (workflows/, models/, jobs/); none are created empty.

oncall-agent/
  gnl.config.ts            the one place agents, tools and protections are wired
  src/agents/assistant.ts  the chat agent (10 lines)
  src/agents/charge-demo.ts  its model calls the tool below
  src/tools/charge-order.ts  a REAL side effect: idempotency: 'args'
  test/proof.test.ts       3 duplicate calls charged once

Start it. gnl dev gives you a REST API, an OpenAPI document and the Studio inspector on one port, with no server file of your own:

Two agents are registered, not one: assistant answers with an echo, and charge-demo calls a tool that moves money. The second one exists so the next section is not a claim you have to take on trust.

Try it
$ pnpm dev
 
gnl dev → REST http://localhost:3000 (auth: open)
OpenAPI http://localhost:3000/openapi.json
Studio http://localhost:3000/studio (Playground)
 
$ curl -XPOST localhost:3000/agents/assistant/run \
-H 'content-type: application/json' \
-d '{"runId":"r1","prompt":"hello"}'
{"ok":true,"runId":"r1","text":"echo: hello","interrupts":[]}

Two minutes in: watch a duplicate get refused

This is the whole argument, runnable before you have written a line. The test that ships with the project reproduces a documented AI SDK pattern — a model emitting the same tool call three times in one turn, each under a different id — and asserts the charge happens once.

$ pnpm test
 one order, 3 duplicate tool-calls in one turn charged exactly once
 crash-resume replays instead of re-charging

$ curl -si localhost:3000/agents/charge-demo/run \
    -H 'content-type: application/json' \
    -d '{"workKey":"order-1","resourceId":"me","prompt":"charge order 1"}' | grep x-gnl
  x-gnl-idempotency-status: new

$ # the exact same command again
  x-gnl-idempotency-status: replay the model was not called; the record answered

$ # same workKey, different content
  409 run_input_mismatch one workKey is one job

2The journal — where every run is recorded

@gnldev/durable

The journal is the framework: it makes a retried run replay instead of repeat, holds a suspended run while it waits for a human, and is what a duplicate is checked against. You already answered this at the gate — In a file here wrote a SQLite line into gnl.config.ts, and nothing had to be installed. For Postgres, answer the third question differently (or pass the flag):

install
npm create gnl@latest oncall-agent --store pg
// gnl.config.ts — written by `gnl init`, not by hand.
import { PostgresStorage } from '@gnldev/durable/postgres';

export default {
  // Postgres, because you said the journal lives in one. `DATABASE_URL` is read at startup and
  // is NOT defaulted — a journal that silently falls back to a local file is a journal you
  // discover is empty in production.
  storage: new PostgresStorage({ connectionString: process.env.DATABASE_URL! }),
  agents: { assistant, 'charge-demo': { ...chargeDemo, tools: { chargeOrder } } },
  preset: 'assistant',
};

One storage object, five ports — runs, memory, vectors, work, cache — in the same database and the same transaction boundary. Every adapter implements the same interface, so the line above is the only difference between a file on your laptop and a cluster.

There is no migration step: the schema is created on first use. And no rewrite later — moving from the file to Postgres is this one line, which is precisely why the question is asked on day one rather than discovered on day ninety.

Try it
$ docker run -d --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=agent -p 5432:5432 postgres:16-alpine
$ export DATABASE_URL=postgres://postgres:pw@localhost:5432/agent
 
$ # after the first run, the schema is there:
$ psql $DATABASE_URL -c '\dt'
public | gnl_cache | table
public | gnl_counters | table
public | gnl_messages | table
public | gnl_run_journal | table
public | gnl_runs | table
public | gnl_threads | table
public | gnl_vectors | table

3A real model — one command

@ai-sdk/openai

The starter answers with an echo, on purpose: no key, no account, nothing to sign up for before you have seen the thing work. Switching to a real provider is one command — it writes the file, adds the dependency and tells you the single import line to change:

install
npx gnl add model nvidia      # or: openai · anthropic · openai-compatible
pnpm install
// src/models/nvidia.ts — written by `gnl add model nvidia`.
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const nim = createOpenAICompatible({
  name: 'nvidia',
  baseURL: 'https://integrate.api.nvidia.com/v1',
  apiKey: process.env.NVIDIA_API_KEY ?? '',
});

export const model = nim(process.env.NVIDIA_MODEL ?? 'poolside/laguna-xs-2.1');

// then in src/agents/assistant.ts, two lines:
//   import { model } from '../models/nvidia.js';
//   …and in the config object:  model,   (replacing echoModel())

It writes src/models/nvidia.ts and an .env.example naming the variables that file reads. Put the key in .env: gnl dev and gnl studio load it at startup, and anything already set in your shell wins. .env is gitignored; .env.example is not.

Two things that will cost you an hour
Use .chat(id), not nvidia(id) — the bare call returns the RESPONSES model, which NVIDIA does not serve; the request 404s with a message about a function id and reads like a bug in your code. And the routing prefix wraps the WHOLE id: the router splits on the first slash and passes the rest through untouched, so NVIDIA's own models — whose ids already start with nvidia/ — need it doubled: nvidia/nvidia/nemotron-…. Building the string in one place, as above, means nobody has to remember that.

4Tools — what the agent can do

aizod

An agent with no tools is a chat endpoint. Ours gets two, and the difference between them is the subject of the next four sections.

install
pnpm add ai zod
// src/tools.ts
import { tool } from 'ai';
import { z } from 'zod';

/** Stands in for the fleet, so the example can count what actually happened. */
export const fleet = { restarts: [] as string[] };

/**
 * The one thing this agent can DO — and the reason the framework is here.
 *
 * Restarting a service twice is two outages. Unlike a double charge there is nothing to refund,
 * which is why "it probably won't happen twice" is not a design.
 */
export const restartService = tool({
  description: 'Restarts a service. Disruptive.',
  inputSchema: z.object({ service: z.string() }),
  execute: async ({ service }) => {
    fleet.restarts.push(service);
    return { service, restarted: true };
  },
});

First, the tool that changes something:

/**
 * A read — and the source of every secret in this tutorial.
 *
 * An agent that can act on a service is an agent that reads that service's logs, and real log lines
 * carry connection strings and customer addresses.
 */
export const readLog = tool({
  description: 'Reads the tail of a service log.',
  inputSchema: z.object({ service: z.string() }),
  execute: async ({ service }) => ({
    service,
    lines: [
      'INFO  boot: connected postgres://svc:hunter2@db-primary/orders',
      'INFO  request: customer [email protected] placed order',
      'ERROR gc: heap 1.9GB / 2.0GB',
    ],
  }),
});

// Reading a log twice is harmless, so this opts OUT of the default side-effect treatment.
// Part 7 measures exactly what that changes. Never put it on a tool that writes.
Object.assign(readLog, { idempotent: true });

export const tools = { restartService, readLog };

Note the imports: tool from ai and z from zodnot from GNL. This is an ordinary Vercel AI SDK tool. If your project already has tools, they are already in this shape and need no porting.

Second, the tool that only reads — and the source of every secret in this tutorial:

5The agent and its system prompt

createGnl takes storage and a map of agent definitions — but you rarely call it directly. Write the config first:

src/agent.ts
// src/agent.ts — the config, and nothing else.
//
// Note what this file does NOT do: it never calls the agent. It describes one. Who calls it is the
// next file's problem, and for HTTP traffic the answer is "the framework does".
import type { CreateGnlConfig } from '@gnldev/durable';
import { storage } from './storage.js';
import { MODEL } from './model.js';
import { tools } from './tools.js';

export const config: CreateGnlConfig = {
  storage,
  agents: {
    ops: {
      model: MODEL,
      tools,
      // The system prompt is the agent's JOB DESCRIPTION — the one place you say what it is for.
      // Everything you add from here constrains what it MAY DO. They are not substitutes: a prompt
      // asking the model nicely not to restart things is not a guard.
      system:
        'You are an on-call engineer. Read the service log before acting, and never restart a ' +
        'service unless the log shows it is genuinely stuck.',
      // A budget, not a target: caps how many tool-call rounds one run may take, which is what stops
      // a confused model looping.
      maxSteps: 4,
    },
  },
};

Now hand that config to @gnldev/server and you are done. createRestApi calls createGnl internally and serves every agent in the config over HTTP — you never write gnl.run() for request traffic:

src/server.ts
// src/server.ts — mount it. This is the whole integration.
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { createRestApi } from '@gnldev/server';
import { config } from './agent.js';

const app = new Hono();
app.mount('/api', createRestApi(config, { title: 'On-call', allowOpenAccess: true }));

serve({ fetch: app.fetch, port: 3000, hostname: '127.0.0.1' });

The system prompt is the agent's job description — the one place you say what it is for. Everything you add from here constrains what it may do. They are not substitutes for each other: a prompt asking the model nicely not to restart things is not a guard, and a guard does not tell the model what its job is.

Try it
$ curl -XPOST localhost:3000/api/agents/ops/run \
-H 'content-type: application/json' \
-d '{"runId":"triage:INC-42","prompt":"The checkout service looks stuck. Investigate and fix it."}'
 
{"ok":true,"runId":"triage:INC-42",
"text":"The checkout service has been restarted. The issue was clear from the logs:
**Problem Identified:** The service was using 1.9GB of a 2.0GB heap…",
"interrupts":[],"finishReason":"stop"}

That answer came from a live model deciding, on its own, to read the log and then restart the service — reached over HTTP, with no gnl.run() anywhere in your code.

6Choosing the runId

You saw runId in that request body. It is the most important argument in the API, and the one thing the framework will not decide for you.

Send the same one twice and the second costs nothing — no tool call, and the model is not consulted. Omit it on the REST path and you get a 400:

Twenty-one seconds, then under a second. You configured nothing to get that; it is what the journal is. Change the id and the agent runs again, correctly — the journal deduplicates a repeat of the same run, it does not mute a tool.

Try it
$ # POST it once — the model runs, the tools run
$ curl -XPOST localhost:3000/api/agents/ops/run -d '{"runId":"triage:INC-42","prompt":"…"}'
{"ok":true,"runId":"triage:INC-42","text":"The checkout service has been restarted…"}
real 0m21.4s
 
$ # POST the SAME runId again — the journal answers. No model call, no tool call.
$ curl -XPOST localhost:3000/api/agents/ops/run -d '{"runId":"triage:INC-42","prompt":"…"}'
{"ok":true,"runId":"triage:INC-42","text":"The checkout service has been restarted…"}
real 0m0.852s
 
$ # Omit it entirely:
$ curl -XPOST localhost:3000/api/agents/ops/run -d '{"prompt":"…"}'
400 {"error":"runId required (idempotency key)"}

Who supplies it depends on which door the work comes through:

Try it
useChat → POST /chat derived `${body.id}:${lastMessage.id}`
POST /api/agents/:name/run REQUIRED 400 without it
POST /api/workflows/:name/run REQUIRED and the ONLY way to resume one
gnl.run() from your own code REQUIRED queue workers, workflow steps, cron
Why the framework does not generate one
A generated id would be unique per call, so every retry would be a brand-new job and every guarantee in this tutorial would be silently off. The right id answers "what is this work ABOUT" — an incident id, an order id, a Stripe event id, a queue message id — and only your caller knows that. The chat route is the one exception: a turn already has an id, so it derives one. Never a timestamp, never a random value.

7Failure is not replay

Replay covers the step that succeeded. The harder case fails halfway: the restart landed, and then the acknowledgement was lost. From outside that looks like a failure, and a naive retry causes the second outage.

GNL treats every tool as side-effecting for exactly that reason and refuses to re-run a failed one. You can declare it explicitly by attaching sideEffect: true to the tool — but read the measurement before you do:

Writing sideEffect: true changes nothing. It is already the default: the resolution is tool.sideEffect ?? tool.idempotent !== true, so everything is side-effecting until it says otherwise. The declaration that changes behaviour is the opposite one — idempotent: true, the line we put on readLog — which opts a tool INTO being retried.

Try it
$ # a tool that FAILS after its side effect already landed, retried once:
 
declared nothing executions: 1 ← refused on retry
declared sideEffect executions: 1 ← identical: it was already the default
declared idempotent executions: 2 ← opted IN to being repeated

Keep that as a habit rather than a fact: the safe behaviour is the default, and what you write down is the exception. A tutorial that told you to add a line "to get exactly-once" would have taught you to trust a line that does nothing.

8Guards — when a human decides

Durability stops an action happening twice. It says nothing about whether it should happen at all, and restarting production is a decision a person makes.

// src/agent.ts — add the guard
//
// A guard runs before EVERY tool call and answers allow | require-approval. This one stops exactly
// one tool: a gate on a read costs an engineer's attention and buys nothing, and a system that asks
// about everything is a system people learn to click through.
export const guard = ({ toolName, args }: any) =>
  toolName === 'restartService'
    ? { action: 'require-approval' as const, reason: `restarting ${args?.service} interrupts live traffic` }
    : { action: 'allow' as const };

export const gnl = createGnl({
  storage,
  agents: { ops: { model: MODEL, tools, guard, system: '…', maxSteps: 4 } },
});

/** Continues a suspended run once a human has answered. */
export const resume = (runId: string, approvals: Record<string, boolean>) =>
  resumeRun(runId, { journal: storage.runs, model: MODEL, tools, guard, stopWhen: stepCountIs(4), approvals });

Now the run suspends instead of calling the tool, and comes back with an interrupt describing what it wanted to do and why it stopped:

const r = await gnl.run('ops', { runId: `triage:${incidentId}`, prompt });

if (r.interrupts.length) {
  const it = r.interrupts[0];
  console.log(`⏸  ${it.toolName} — ${it.reason}`);

  // In a real app this is an HTTP endpoint a human hits (Part 19), not a line below the run.
  // The point of suspending is that the answer does not have to come from this process, or this hour.
  const done = await resume(`triage:${incidentId}`, { [it.toolCallId]: true });
}

Suspending matters more than it looks. Approval can take a minute or an hour, and a process holding a socket open for an hour is a process that gets redeployed before the answer arrives. The run is not waiting in memory — it is written down, and resume picks it up later, possibly in an entirely different process.

Try it
$ pnpm start INC-42
 
⏸ waiting for approval: restartService — restarting checkout interrupts live traffic
the run is written down, not held in memory. Approve it whenever, from anywhere.
 
✅ approved: The checkout service has been restarted…

9Processors — what the model may see

@gnldev/processors

Guards constrain what the agent may do. Processors constrain what it may see — and an agent that reads logs needs them, because those lines carry connection strings and customer addresses.

install
pnpm add @gnldev/processors
// src/agent.ts — what the model is allowed to SEE
import { piiRedactor } from '@gnldev/processors';

export const gnl = createGnl({
  storage,
  processors: [
    piiRedactor({
      // Off by default because it changes what gets written to the journal. For an agent whose job
      // is reading logs, it is the entire point.
      redactToolResults: true,
      // The built-in set is PII — email, phone, card, IBAN, SSN, IP. A database password is a
      // CREDENTIAL, not PII, and is measurably not covered. This closes that, and keeps the HOST
      // readable: at 3am "which database" is the question, and [REDACTED] answers none of it.
      extraPatterns: [
        { name: 'dsn', pattern: /(?<=:\/\/)[^\s:@/]+:[^\s@/]+(?=@)/g, mask: '[REDACTED_DSN]' },
      ],
    }),
  ],
  agents: { ops: { model: MODEL, tools, guard, system: '…', maxSteps: 4 } },
});

The built-in set is PII — email, phone, card, IBAN, SSN, IP. A database password is a credential, not PII, and is measurably not covered. Wiretap the model and count:

With only the defaults, hunter2 reaches the model while the address beside it is correctly masked. Note the third line too: the pattern keeps the host, because "which database" is the question you are actually asking at 3am. A rule that masked the whole URL would pass the first two checks and be useless to the person reading the trace.

Try it
$ # wiretap the model and count what actually reaches it:
 
without processor — password visible : true
with processor — password visible : false
with processor — host still readable : true

10Provider schema compatibility

@gnldev/tool-schema

One line, and it is invisible until the day it is not:

install
pnpm add @gnldev/tool-schema
// src/agent.ts
import { defaultRules } from '@gnldev/tool-schema';

export const gnl = createGnl({
  storage,
  processors: [...],
  // Rewrites each tool's JSON Schema for whichever provider is actually underneath. Invisible until
  // the day you point this same config at a provider in strict mode and a schema shape it silently
  // rejects takes the agent down.
  schemaCompat: defaultRules,
  agents: { ops: { model: MODEL, tools, guard, system: '…', maxSteps: 4 } },
});

defaultRules rewrites each tool's JSON Schema for whichever provider is actually underneath. Point this same config at a provider running strict mode, and a schema shape it silently rejects takes the whole agent down. This is the line that prevents it — worth adding now, while you have one agent, rather than during the incident where you find out.

11Embeddings

@gnldev/memory

The next two sections both need "find the relevant thing by meaning": an alert reading "RSS climbing" and a runbook titled "memory saturation" share no keyword, and a substring search finds nothing.

install
pnpm add @gnldev/memory
// src/embed.ts — one function from string to vector, used by memory AND retrieval.
const EMBED_MODEL = process.env.NVIDIA_EMBED_MODEL ?? 'nvidia/nemotron-3-embed-1b';

/**
 * NVIDIA requires `input_type`: 'query' for what you search WITH, 'passage' for what you search
 * THROUGH. Sending the wrong one still returns a vector, so nothing errors — retrieval just gets
 * quietly worse, which is the hardest kind of bug to notice.
 */
async function embedWith(input: string, inputType: 'query' | 'passage'): Promise<number[]> {
  const res = await fetch('https://integrate.api.nvidia.com/v1/embeddings', {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.NVIDIA_API_KEY}` },
    body: JSON.stringify({ model: EMBED_MODEL, input: [input], input_type: inputType }),
  });
  if (!res.ok) throw new Error(`embeddings ${res.status}`);
  const json = await res.json();
  return json.data[0].embedding;
}

export const embed = (t: string) => embedWith(t, 'query');
export const embedPassage = (t: string) => embedWith(t, 'passage');

It is a plain function from string to vector — nothing in GNL asks for more, so the same function serves memory recall and the runbook store, and swapping providers changes only this file.

Try it
$ # does it actually rank the right runbook higher?
 
dimensions : 2048
vs memory runbook : 0.3157
vs disk runbook : 0.1758
picked the right one : true

12Memory

An agent that forgets the last incident is an agent that asks you the same questions every time.

// src/agent.ts — memory
import { AgentMemory } from '@gnldev/memory';
import { z } from 'zod';
import { embed } from './embed.js';

const memory = new AgentMemory({
  storage,               // same Postgres — messages, threads and vectors all land there
  embed,
  recentN: 8,            // the last 8 turns verbatim
  // Recall scoped to the SERVICE, not the caller: at 3am what matters is what happened to `checkout`
  // last time, whoever was holding the pager then.
  recall: { topK: 3, scope: 'resource' },
  // Structured facts the agent keeps across turns, validated by a schema rather than hoped for.
  workingMemory: { schema: z.object({ service: z.string().optional(), severity: z.string().optional() }) },
});

export const gnl = createGnl({
  storage,
  memory,                // TOP-LEVEL, not inside the agent — an agent-level `memory` is ignored
  processors: [...],
  schemaCompat: defaultRules,
  agents: { ops: { model: MODEL, tools, guard, system: '…', maxSteps: 4 } },
});

Recall is scoped to the resource, not the caller: at 3am what matters is what happened to checkout last time, whoever was holding the pager then. workingMemory is the structured half — facts the agent keeps across turns, validated by a schema rather than hoped for.

await gnl.run('ops', {
  runId: `triage:${incidentId}`,
  prompt,
  threadId: incidentId,   // the conversation — memory continuity across turns
  resourceId: service,    // WHOSE memory — recall is scoped to this
});
memory goes top-level, not inside the agent
createGnl reads memory from the top-level config. Put it inside an agent definition and it is silently ignored — no error, no warning; you find out when the threads table is empty.

13Runbooks — retrieval

@gnldev/rag@gnldev/cache

An on-call agent that improvises is worse than no agent. It has to answer "what does this organisation do about this alert", and that answer lives in prose someone wrote at 3am after it happened last time.

install
pnpm add @gnldev/rag @gnldev/cache
// src/runbooks.ts — what the agent reads before it acts.
//
// An on-call agent that improvises is worse than no agent. It has to answer "what does THIS
// organisation do about this alert", and that lives in prose someone wrote at 3am last time.
import { InMemoryVectorStore, indexDocuments, createRagTool } from '@gnldev/rag';
import { createCache } from '@gnldev/cache';
import { storage } from './storage.js';
import { embed, embedPassage } from './embed.js';

const RUNBOOKS = [
  { id: 'rb-memory', text:
    'RUNBOOK — memory saturation. Applies when memory_rss_mb exceeds 1800. Read the service log ' +
    'first; a leak shows as rising RSS with flat request volume. If it matches, restart the ' +
    'service — a restart is disruptive and needs SRE approval. Then watch for 30 minutes: a leak ' +
    'that returns inside that window is a code bug, escalate rather than restart again.' },
  { id: 'rb-latency', text:
    'RUNBOOK — latency regression. Applies when p99 exceeds 2000ms. Do NOT restart. Latency is ' +
    'almost always downstream: check the database, then the cache hit rate.' },
  { id: 'rb-escalation', text:
    'RUNBOOK — escalation. Page the on-call engineer once and only once per incident. A second page ' +
    'for the same incident trains people to ignore the first.' },
];

// The corpus is re-indexed on boot. Cache the embeddings and you pay for each text once, ever —
// across runs, across restarts, because the key is the content, not the run.
const cache = createCache(storage.cache, 'embed');
const cachedPassage = (t: string) => cache.getOrCompute(t, () => embedPassage(t));

export async function buildRunbookTool() {
  const store = new InMemoryVectorStore();
  await indexDocuments(store, cachedPassage, RUNBOOKS);
  // Returns an ORDINARY tool — the agent calls it by name like any other.
  return createRagTool({ store, embed, topK: 1,
    description: 'Finds the runbook for an alert. Always read this before acting.' });
}

createRagTool returns an ordinary tool — the agent calls searchRunbook by name like any other. And note the cache: the corpus is re-indexed on boot, so without it you pay for every embedding on every restart. The key is the content, not the run, so the reuse spans processes.

Every rule in the rest of this tutorial traces back to one of those three documents: needs SRE approval became the guard, do NOT restart becomes a scorer in section 19, once and only once becomes the workflow you are about to write.

14Workflows — procedure, not prompt

@gnldev/workflow

The agent's job is judgement: which runbook applies, what the numbers mean. The pipeline's job is procedure: triage, escalate, record. Procedure written into a system prompt is procedure you cannot audit and cannot replay — and "page the on-call engineer once per incident" is procedure.

install
pnpm add @gnldev/workflow
src/workflow.ts
// src/workflow.ts — procedure, not more prompt.
//
// The agent's job is JUDGEMENT: which runbook applies, what the numbers mean. The pipeline's job is
// PROCEDURE: triage, escalate, record. Procedure written into a system prompt is procedure you
// cannot audit and cannot replay — and "page once per incident" is procedure.
import { workflow, step } from '@gnldev/workflow';

// A step's output is the next step's input. Each is journaled under `runId#stepId`.
const triage = step('triage', async (alert: Alert, ctx) => {
  // HERE is where you call the agent yourself — you are driving it from your own code, not serving
  // an HTTP request. This is the case `gnl.run()` exists for.
  const r = await gnl.run('ops', {
    runId: `triage:${alert.incidentId}`,
    prompt: alert.text,
    threadId: alert.incidentId,
    resourceId: alert.service,
  });
  const it = r.interrupts?.[0];
  return { ...alert, diagnosis: r.text ?? '', suspended: !!it, awaiting: it };
});

const escalate = step('escalate', async (t) => {
  if (t.severity !== 'sev1') return { ...t, paged: false };
  await page('sre-oncall', t.incidentId);
  return { ...t, paged: true };
});

const close = step('close', async (r) => { await record(r); return r; });

export const incident = workflow<Alert>().then(triage).then(escalate).then(close);

Notice where gnl.run() finally appears: inside a step. That is the case it exists for — you are driving the agent from your own code rather than serving a request, so nobody else can supply the runId.

// src/agent.ts — register it alongside the agents.
export const config: CreateGnlConfig = {
  storage,
  agents: { ops: { … } },
  workflows: { incident },     // now reachable at POST /api/workflows/incident/run
};

Register it in the same config and it is served like an agent is:

Try it
$ curl -XPOST localhost:3000/api/workflows/incident/run \
-d '{"runId":"wf:INC-42","input":{"incidentId":"INC-42","service":"checkout","severity":"sev1"}}'
 
$ # A step suspended (the guard). POST the SAME runId to continue — completed steps replay,
$ # the suspended one is re-evaluated. This endpoint is the ONLY way to resume a workflow.
$ curl -XPOST localhost:3000/api/workflows/incident/run -d '{"runId":"wf:INC-42"}'

Each step is journaled under runId#stepId, which is why this is a separate file rather than three more lines inside the agent: a process that dies after escalate and before close does not page again on restart — it comes back holding the recorded result and moves straight on.

15Queue, events, scheduler

@gnldev/queue@gnldev/events@gnldev/scheduler

rb-memory ends with a promise about a moment thirty minutes in the future, made by a process that will very likely be redeployed before then. Three packages turn that from an intention into a fact.

install
pnpm add @gnldev/queue @gnldev/events @gnldev/scheduler
// src/ops.ts — how alerts arrive, and what watches afterwards.
import { enqueue, createWorker } from '@gnldev/queue';
import { emit, createConsumer } from '@gnldev/events';
import { scheduleWorkflow, pollScheduler } from '@gnldev/scheduler';

// ── queue: alerts are durable JOBS, not function calls ──────────────────────
// A crash mid-triage leaves the job pending, so it is picked up again instead of vanishing with the
// process that held it. The job id IS the incident id — a monitor that fires the same alert three
// times (they do) enqueues one job.
export const submitAlert = (alert: Alert) =>
  enqueue(storage.work, 'alert', alert, { id: `alert:${alert.incidentId}` });

const worker = createWorker(storage, {
  alert: async (payload) => { await incidentWorkflow.run(payload as Alert,
    { runId: `wf:${(payload as Alert).incidentId}`, journal: storage.runs }); },
});

// ── events: a restart publishes a FACT; whoever cares subscribes ────────────
// The agent does not know a watch exists, which is what lets you change the follow-up policy later
// without touching the prompt.
export const announceRestart = (incidentId: string, service: string) =>
  emit(storage.work, 'service.restarted', { incidentId, service }, { id: `restarted:${incidentId}` });

// ── scheduler: the 30-minute watch, as a row with a time on it ──────────────
// rb-memory promises something about a moment thirty minutes from now, made by a process that will
// very likely be redeployed before then. setTimeout keeps that promise only if nothing restarts.
const watcher = createConsumer(storage.work, 'service.restarted', async (p: any) => {
  await scheduleWorkflow(storage.runs, {
    id: `watch:${p.incidentId}`,           // idempotent on this id — a redelivered event schedules ONE watch
    name: 'post-restart-watch',
    input: { incidentId: p.incidentId, service: p.service },
    at: Date.now() + 30 * 60 * 1000,
  });
}, { name: 'post-restart-watcher' });

worker.start();
watcher.start();
// `.catch` is not decoration: a rejected promise from a bare interval is an unhandled rejection, and
// Node's default is to kill the process — so one transient poll error takes down alert intake too.
setInterval(() => { pollScheduler(storage.runs, runner).catch((e) => console.error('[sched]', e)); }, 5_000).unref();

Queue: alerts are durable jobs, so a crash mid-triage leaves the job pending rather than losing it with the process. Events: a restart publishes a fact and whoever cares subscribes — the agent does not know a watch exists, which is what lets you change the follow-up policy without touching the prompt. Scheduler: the watch is a row in the journal with a timestamp, so it outlives the deploy.

Two stores, and mixing them up is a runtime error
TWO STORES, and mixing them up is a runtime error rather than a type error: queue and events append to `storage.work`, while the scheduler keeps triggers in `storage.runs` (it needs `listKeys` for trigger enumeration, which the work store does not have). Both live in the same Postgres database, so passing the wrong one type-checks fine and fails on the first poll.

16A second agent, over the network

@gnldev/a2a@gnldev/mcp

rb-escalation has a second sentence: if a second opinion is needed, ask the database specialist rather than paging again. That is why this is a separate agent and not a longer system prompt, and the reason has nothing to do with model quality.

install
pnpm add @gnldev/a2a @gnldev/mcp @gnldev/server
// src/specialist.ts — a SECOND agent, with its own blast radius.
//
// rb-escalation says: "if a second opinion is needed, ask the database specialist rather than
// paging again." That is why this is a separate agent and not a longer system prompt — the
// specialist's tools reach a production database, and the triage agent runs on whatever pages it.
// One agent with both toolsets is one blast radius covering both.
import { createA2ATool } from '@gnldev/a2a';
import { createMcpTools, connectMcp } from '@gnldev/mcp';
import { createRestApi } from '@gnldev/server';

// MCP: the specialist's tools come from the org's database-inspection server, not from this repo.
// Calls made through it are journaled, so a resumed run does not re-query.
const client = await connectMcp({ type: 'stdio', command: 'db-mcp' });
const dbTools = await createMcpTools(client, { prefix: 'db_' });

const specialistApp = createRestApi({
  storage,
  agents: { dbSpecialist: { model: MODEL, tools: dbTools, maxSteps: 4,
    system: 'You are a database specialist. Answer with the query or pool number that explains the symptom.' } },
}, { title: 'DB Specialist', allowOpenAccess: true });

// A2A: the triage agent sees one more ordinary tool. Behind it is a POST to the specialist's REST
// API, wrapped so a timeout or a 500 becomes a failed tool result the triage model can read and
// react to — rather than an unhandled exception.
export const askSpecialist = createA2ATool({
  endpoint: process.env.SPECIALIST_URL ?? 'http://specialist.internal',
  agentName: 'dbSpecialist',
  description: 'Asks the database specialist for a second opinion. Use this instead of paging again.',
  timeoutMs: 10_000,
});

MCP brings in tools the specialist's organisation owns, not code in your repo — and calls made through it are journaled, so a resumed run does not re-query. A2A makes the specialist reachable as one more ordinary tool from the triage agent's point of view, wrapping a timeout or a 500 into a failed tool result the model can read and react to rather than an unhandled exception.

17The HTTP surface

@gnldev/server@gnldev/studio@gnldev/auth@gnldev/chat-adapter

Everything so far has been logic. This gives it a surface — and the approval endpoint is why it is not just createRestApi called once: a restart waits for a person, and a person needs somewhere authenticated to say yes.

install
pnpm add @gnldev/server @gnldev/studio @gnldev/auth @gnldev/chat-adapter hono @hono/node-server
// src/server.ts — the surface a human touches.
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { createRestApi } from '@gnldev/server';
import { createStudioApp } from '@gnldev/studio';
import { createChatRoute } from '@gnldev/chat-adapter';
import { roleAuth, makeGate } from '@gnldev/auth';
import { toJournal } from '@gnldev/durable';

// Two credential classes, because during an incident they are genuinely different people: everyone
// needs to read the journal immediately, and exactly one person may say yes to taking production
// down. One token for both means everyone who can watch can also restart.
const auth = roleAuth({
  admin: process.env.ONCALL_ADMIN ? { token: process.env.ONCALL_ADMIN } : undefined,
  viewer: process.env.ONCALL_VIEWER ? { token: process.env.ONCALL_VIEWER } : undefined,
});
const gate = makeGate(auth, { allowOpenAccess: !auth });

const app = new Hono();

app.post('/alerts', async (c) => {
  const body = await c.req.json();
  const jobId = await submitAlert(body);
  // 202, not 200: nothing has been triaged yet. Answering 200 with an empty diagnosis is how a
  // monitoring system learns to stop trusting the acknowledgement.
  return c.json({ accepted: true, jobId }, 202);
});

// The ONLY endpoint that can release a suspended restart. Note what is NOT here: any way to say
// "run the restart" — only a way to release a decision the agent already reached and the journal
// already recorded, addressed by that exact toolCallId.
app.post('/incidents/:runId/approve', async (c) => {
  if (!(await gate.allow(c.req.raw, 'write'))) return gate.deny(c.req.raw, 'write');
  const { toolCallId, approved } = await c.req.json();
  const r = await resume(c.req.param('runId'), { [toolCallId]: approved !== false });
  return c.json({ text: r.text, interrupts: r.interrupts });
});

// A streaming route the AI SDK's useChat talks to directly.
app.route('/chat', createChatRoute({ gnl }, { resolveThreadId: (_c, b) => b?.incidentId }));

// The generic REST API over agents/runs/workflows, and the journal inspector.
app.mount('/studio', createStudioApp({ reader: toJournal(storage.runs), apiBase: '/studio', auth }));
app.mount('/api', createRestApi({ storage, agents }, { auth, allowOpenAccess: !auth }));

// Loopback unless an address is named. A bare serve() binds every interface, which for an approval
// endpoint with no token is an admin surface offered to the network.
serve({ fetch: app.fetch, port: 3000, hostname: process.env.HOST ?? '127.0.0.1' });

Four things on one app: your own routes, a streaming /chat the AI SDK's useChat talks to, the generic REST API over agents and runs, and Studio — the journal inspector, approval queue and time-travel UI, reading the same journal everything else writes to.

That is measured, not asserted. Note what the approve endpoint cannot do: there is no way to say "run the restart" — only to release a decision the agent already reached and the journal already recorded, addressed by that exact toolCallId.

Try it
$ export ONCALL_ADMIN=s3cr3t
 
$ curl -XPOST localhost:3000/incidents/triage:INC-42/approve -d '{"toolCallId":"…"}'
403 # restarts: 0
 
$ curl -XPOST … -H 'authorization: Bearer wrong' -d '…'
403 # restarts: 0
 
$ curl -XPOST … -H 'authorization: Bearer s3cr3t' -d '…'
200 # restarts: 1

18Mounting into an app you already have

Nothing above requires a greenfield project. GNL's HTTP surfaces are fetch handlers, so they mount into whatever you are already running — Hono takes them directly, and Express, Fastify, Koa and bare node:http go through one adapter:

install
pnpm add @gnldev/durable @gnldev/server @gnldev/studio
// Express, Fastify, Koa and bare node:http go through one adapter.
import express from 'express';
import { toNodeHandler } from '@gnldev/server/node';
import { api, studio } from './gnl.js';

const server = express();

// YOUR routes first, with the body parser scoped to them.
//
// The one mistake worth naming: a GLOBAL express.json() mounted BEFORE GNL drains the request
// stream and hands the result to Express, not to us — the handler then sees a POST with no body
// and answers "runId is required" to a request that plainly carried one.
server.use('/app', express.json(), yourRouter);

server.use('/studio', toNodeHandler(studio));
server.use('/agents', toNodeHandler(api));

server.listen(3000, '127.0.0.1');

gnl init --host express writes these two files into an existing project if you would rather not type them.

Mount before the body parser
And on Fastify, bind at the middleware layer rather than as a route — middleware runs before body parsing, a route runs after it. A parser that runs first reads the stream to the end and hands the result to the framework instead of to us; the handler then sees a POST with no body and answers runId is required to a request that plainly carried one. Your own routes keep their parser.

19Scoring and tracing

@gnldev/evals@gnldev/otel

"Did it follow the runbook" is a different question from "did it work", and both are answered from the journal after the fact — not from log lines the agent was asked to emit.

install
pnpm add @gnldev/evals @gnldev/otel
// src/observe.ts — did it follow the runbook, and what did it cost?
//
// Both answered from the JOURNAL after the fact, not from log lines the agent was asked to emit. A
// finished run can be scored, re-scored under a stricter rule later, and traced — because what
// happened is a data structure, not a transcript that existed once.
import { trajectoryScorerFor, scoreRun } from '@gnldev/evals';
import { exportRun } from '@gnldev/otel';
import { piiTextRedactor } from '@gnldev/processors';
import { toJournal } from '@gnldev/durable';

// The runbook, in a form a build can fail on. Deliberately about the SHAPE of the run, not the
// wording of the answer: a diagnosis can be phrased a hundred ways, but "called restartService
// during a latency incident" either happened or it did not.
export const RUNBOOK_RULES = {
  memory:  { requiredTools: ['searchRunbook', 'getMetric'], maxToolCalls: 8 },
  latency: { requiredTools: ['searchRunbook'], forbiddenTools: ['restartService'], maxToolCalls: 8 },
};

export const scoreIncident = (runId: string, rules) => {
  const reader = toJournal(storage.runs);
  return scoreRun(reader, runId, [trajectoryScorerFor(reader, { ...rules, name: 'runbook-adherence' })]);
};

// `redact` matters here specifically: the run's verdict is written by a path NO processor sees, so a
// provider that quotes the offending input back inside a refusal puts that text into the span raw —
// and from there into a collector that is usually somebody else's.
export const traceIncident = (runId: string) =>
  exportRun(toJournal(storage.runs), runId, {
    endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
    serviceName: 'oncall-agent',
    redact: piiTextRedactor(),
  });

RUNBOOK_RULES is the part worth copying into your own project: rb-memory and rb-latency are prose an engineer reads once; these are the same rules in a form that checks every future run automatically. A finished run can even be re-scored later under a stricter rule, because the record is a data structure rather than a transcript that existed once.

Try it
GET /incidents/triage:INC-42/score
 
{
"runId": "triage:INC-42",
"scores": {
"runbook-adherence": {
"score": 1,
"reason": "required: all 2 required tools present [score=1.00, weight=0.50] | budget: 4/8 tool calls (within budget) [score=1.00, weight=0.50] | => composite 1.000"
}
}
}

20Prove it by breaking it

A green test proves nothing on its own. Every guarantee above should be verified the same way: remove the mechanism, and watch a specific test go red.

Write the test for the behaviour, then delete the line that provides it. If the suite stays green, the line was never doing what you thought — which is exactly how the sideEffect measurement in section 7 was found.

the dsn extraPatternthe database password reaches the model verbatim
the guardproduction is restarted with nobody approving it
idempotent: true on readLoga failed read is refused instead of retried
the workflow journala resumed run pages the engineer a second time

Write the test for the behaviour, then delete the line that provides it. If the suite stays green, the line was never doing what you thought — which is exactly how the sideEffect measurement in section 7 was found.

What you built

One project, twenty packages, each added when the scenario earned it — and four properties that are usually bolted on afterwards and never quite hold:

Around them: memory scoped to the resource, runbooks retrieved by meaning with their embeddings cached across restarts, a workflow whose steps are individually replayable, a queue that survives a crash, a scheduler that outlives a deploy, a second agent reached over the network, an authenticated HTTP surface with an inspector, and scoring that reads the journal after the fact.

None of it changed what you learned in the first six sections. It composes on top of it.

  • Durability — a retried run replays its journal instead of repeating work, keyed by a runId you choose.
  • Failure safety — a side-effecting tool that failed is refused rather than repeated, by default, with idempotent: true as the deliberate opt-out for reads.
  • Approval — a guard suspends the run to Postgres instead of blocking a process, and it resumes wherever the answer arrives.
  • Redaction — a processor decides what the model is shown, which for an agent reading production logs is the difference between a trace and a leak.