just-another-sdk

Examples

Runnable projects in the repository.

All ten examples live in the repository and run against a real model — except the last three, which are entirely offline. Clone it, add a key, and go:

git clone https://github.com/KARDT89/just-another-sdk.git
cd just-another-sdk
pnpm install
echo "OPENROUTER_API_KEY=sk-or-v1-..." > examples/.env

1 — Hello, agent

The minimum viable agent: instructions, a model, one question. No tools, no streaming, no session.

pnpm example:hello
examples/01-hello-agent/index.ts
import { Agent, isAgentError } from 'just-another-sdk'
import { openrouter } from 'just-another-sdk/providers'

// Override with OPENROUTER_MODEL to try a different model without editing code.
const MODEL = process.env['OPENROUTER_MODEL'] ?? 'openai/gpt-4o-mini'

try {
  const agent = new Agent({
    name: 'haiku-writer',
    instructions: 'You write haiku. Reply with the poem only — no preamble.',
    model: openrouter(MODEL),
  })

  const result = await agent.run('Write a haiku about a zero-dependency SDK.')
  console.log(result.output)
} catch (error) {
  if (isAgentError(error)) {
    console.error(`✗ ${error.code}\n${error.message}`)
    process.exit(1)
  }
  throw error
}

Note the agent is constructed inside the try: a missing API key is a ConfigurationError raised at construction, and it deserves the same friendly handling as a failed run.

2 — Tools

Everything you actually care about in production, in one file:

  • a typed tool whose handler input is inferred from its Zod schema
  • two tools called in parallel in a single turn
  • a third tool that fails, and an agent that recovers instead of crashing
  • the event stream, rendered by the built-in console tracer
  • a second turn that continues the same conversation
pnpm example:tools
── Turn 1 ────────────────────────────────────────────────────────
▶ run_m9x2k1p  travel-assistant · openai/gpt-4o-mini
  ↳ get_weather {"city":"Paris","unit":"celsius"}
  ↳ get_time {"city":"Paris"}
  ↳ get_air_quality {"city":"Paris"}
    → {"city":"Paris","temperature":18,"unit":"celsius","summary":"clear"} 121ms
    → {"city":"Paris","localTime":"2026-08-01T14:32:00Z"} 63ms
    ✗ {"error":"air-quality upstream returned 503",…} 1ms
✔ finish · 2 turns · 412 in / 63 out · 1.9s

It's 18°C and clear in Paris, and the local time is 14:32.
Air quality data is unavailable right now.

── Turn 2 (same conversation) ────────────────────────────────────
▶ run_m9x3f2q  travel-assistant · openai/gpt-4o-mini
✔ finish · 1 turn · 498 in / 24 out · 0.8s

No umbrella needed — it's clear.

──────────────────────────────────────────────────────────────────
Total across both turns: 910 in / 87 out tokens
Recovered from 1 tool failure(s): get_air_quality

The last line is the point: a tool failed, and the run still produced a useful answer.

3 — Streaming

Four short acts against one agent, each demonstrating one thing:

  • tokens painted as they arrive
  • textStream(), then awaiting the same object for usage and timing
  • streaming with a deliberately slow tool, so the ordering guarantee is visible
  • cancelling a run mid-flight with .abort()

The agent is also configured with maxRetries and a fallbacks chain, so a rate limit or an outage recovers without any code in the example.

pnpm example:stream
── 1. Tokens as they arrive ──────────────────────────────────────
Async iterators exist so a consumer can pull values that are not
ready yet, without blocking or buffering the whole sequence first.

── 3. Streaming with tools ───────────────────────────────────────
(text pauses while the tool runs, then resumes)

Let me look that up.
  ↳ look_up_fact({"topic":"TypeScript"})
    → 803ms
TypeScript was standardised in 1997, which matters because…

── 4. Cancelling mid-stream ──────────────────────────────────────
Concurrency has a long history, beginning with the earliest

  ✔ cancelled cleanly — the model call was aborted mid-flight

The renderer in that file is worth copying: the only real difficulty in painting a stream is that text.delta writes a partial line while everything else wants a whole one. Tracking whether the cursor is mid-line, and breaking before any non-delta output, is the entire trick.

4 — Sessions

A conversation that outlives the process. Run it twice and the second run — a different pid — still knows who you are.

pnpm example:sessions   # introduces itself
pnpm example:sessions   # a new process, same conversation
pnpm example:sessions -- --show    # print the stored transcript
pnpm example:sessions -- --clear   # forget it
① first run
> My name is Ada and I am building a zero-dependency agent SDK. Remember that.

▶ run_msbgluny  memory-demo · openai/gpt-4o-mini
  ↺ session demo-user · 0 messages
  ↥ session demo-user · +2 saved
✔ finish · 1 turn · 42 in / 11 out · 0.9s

② second run
> Without me repeating it: what is my name, and what am I building?

▶ run_msbgluq5  memory-demo · openai/gpt-4o-mini
  ↺ session demo-user · 2 messages
  ↥ session demo-user · +2 saved
✔ finish · 1 turn · 96 in / 19 out · 0.8s

You are Ada, and you are building a zero-dependency agent SDK.

The persistence in that file is one line:

const agent = new Agent({
  name: 'memory-demo',
  model: openrouter(MODEL),
  session: fileSession('./.sessions'), // ← the entire feature
  context: { maxTokens: 8_000 },
})

const chat = agent.session('demo-user')
await chat.run(input)

There is no message bookkeeping anywhere else in the example. See Sessions.

5 — A streaming chat server

A whole chat backend in one file: token streaming, a persisted session per user, tool calls visible to the client, and undo.

pnpm example:server
curl -N "http://localhost:8787/chat?user=ada" -d "My name is Ada."
curl -N "http://localhost:8787/chat?user=ada" -d "What is my name?"
curl -N "http://localhost:8787/events?user=ada" -d "Weather in Paris?"
curl     "http://localhost:8787/history?user=ada"
curl -X POST "http://localhost:8787/undo?user=ada"

The two handlers that matter are two lines each:

// plain tokens
return agent.stream(message, { sessionId: user }).toResponse()

// every event, so a UI can show "calling get_weather…"
return agent.stream(message, { sessionId: user }).toEventResponse()
── /events ──────────────────────────────────────────────
  ↳ tool.start  {"city":"Paris"}
  ↳ tool.end    {"city":"Paris","tempC":18,"summary":"clear"}
  ↳ run.finish  2 turns

The file uses node:http, so it bridges the web Response with Readable.fromWeb once. In Next.js, Hono, Bun, Deno, or Workers you would return it directly.

6 — Resuming after a disconnect

A run that outlives its client. The script plays the whole story out on its own: start, disconnect mid-stream, reconnect, finish.

pnpm example:resumable
① stream id: stream_msbikgj7_cf1lbz

② first client reads, then drops:
   one two three four five
   ✂ connection lost

③ reconnecting from event 7:
   six seven eight nine ten

④ full text: "one two three four five six seven eight nine ten"

The text is continuous across the reconnect — nothing repeated, nothing missing. See Resuming a stream.

7 — Structured output

pnpm example:structured

Four acts. The first needs a key; the rest run offline against mockProvider, which is what makes the repair path something you can watch rather than take on trust.

  • A live model extracts a typed Ticket from a paragraph of customer prose.
  • A model that answers with prose, a code fence, and severity: "high" instead of a number — repaired automatically on the second call.
  • Tools and a schema composed: lookup_customer runs first, then the typed object.
  • maxOutputRetries: 0 against a model that will not comply, so you can see what InvalidOutputError actually carries.
② a malformed first answer, repaired automatically

▶ run_msbk5h1x_ef7vod  triage · mock/test-model
  ⚠ output invalid · 1 issue · repairing 1/2
✔ finish · 1 turn · 20 in / 10 out · 3ms

   output.severity is 4, a number
   steps: turn → repair
   turns: 1 — the repair cost tokens, not a turn

That last line is the design in one place: a repair is billed and traced, but it is not a turn, so maxTurns stays the ceiling it claims to be. See Structured output.

8 — Guardrails and approval

pnpm example:guardrails

Runs entirely offline — the only example that needs no key at all. Four acts:

  • An input guardrail rejects a 20,000-character paste, and prints model calls made: 0. "Rejected before spending a token" is a claim; that line is the receipt.
  • An output guardrail scrubs an email address from the answer and from the transcript, so the session cannot hand the original back next turn.
  • A tool guardrail blocks delete_account. The run still finishes — and the same script is replayed under onToolError: 'throw' to show it finishes there too.
  • A $500 refund suspends for a human. The suspension is JSON.stringify'd and re-parsed to stand in for a network hop, then resumed — once approved, once declined.
  ⊘ guardrail read-only-mode · blocked delete_account · This assistant is in read-only mode.
✔ finish · 2 turns

  ⏸ guardrail refund-cap · needs approval for refund_order · $500 is over the $100 limit.
⏸ Waiting on approval for 1 tool call: refund_order.
  ✔ approved refund_order
✔ finish · 1 turn

See Guardrails.

9 — Handoffs

pnpm example:handoffs

Also entirely offline. A triage agent, two specialists, and the four things that make delegation safe:

  • Routing: triage transfers to billing, which uses its own model, instructions, and tools — and agentPath shows the route.
  • A filter that hands over two messages instead of nine, while the transcript and the session keep all nine.
  • Billing tries to hand the conversation straight back. The cycle is refused, billing reads the refusal, and billing answers.
  • A tool guardrail requiring a person to approve the transfer, suspended, serialized over a fake wire, and resumed.
▶ run_msbpobom_r1evqt  triage · mock/triage
  ↳ transfer_to_billing {"reason":"The user reports a duplicate charge in March."}
    → {"transferred_to":"billing","reason":"…"} 0ms
  ⇄ handoff triage → billing · 4 messages carried · The user reports a duplicate charge in March.
  ↳ lookup_invoice {"month":"2026-03"}
    → {"month":"2026-03","charges":2,"total":"$98.00"} 1ms
✔ finish · 3 turns · 30 in / 15 out · 15ms · triage → billing

   agentPath: triage → billing
   who served each turn: triage, billing, billing

Three turns, one run, one usage total. See Handoffs.

10 — Built-in tools

pnpm example:builtin-tools           # entirely offline
pnpm example:builtin-tools -- --live # the keyless tier, for real

The tool pack, in four acts:

  • Five tools on an agent constructed with nothing but a name and a model — and an eval attempt against calculate that comes back a parse error.
  • Weather for Paris with no API key, through a fixed endpoint the model cannot redirect.
  • A filesystem rooted at one directory, and a model reaching for /etc/passwd getting a tool result rather than a file.
  • http_fetch refusing 169.254.169.254 — the cloud metadata endpoint — while the allowlist says *.
   tools: calculate, current_time, date_math, unit_convert, think
   config: none — this agent was constructed with a name and a model.

  ↳ read_file {"path":"../../../etc/passwd"}
    ✗ "../../../etc/passwd" is outside the directory this agent can access.

  ↳ http_fetch {"url":"http://169.254.169.254/latest/meta-data/…"}
    ✗ 169.254.169.254 is a link-local address (cloud metadata).

See Built-in tools.

11 — Providers and failing over

pnpm example:providers # entirely offline

The native transports, and the part that matters in production:

  • The exact Messages API body the Anthropic provider builds — system hoisted to a top-level field, max_tokens supplied because the API demands one, tools under input_schema.
  • Anthropic returning a 529 overload, the retry policy backing off, and the same run continuing on the next provider. One run id, one usage total, one transcript.
  • Gemini's schema translation, asserted rather than described: the tool schema that leaves the SDK carries neither $schema nor additionalProperties, both of which Gemini rejects and both of which the unsanitized schema contains.
▶ run_msbtqrra_benqqz  assistant · claude-opus-5
  ⟳ retry 1/2 · provider_error · waiting 215ms
  ⇄ fallback → gemini-2.5-pro · after provider_error
  ↳ get_weather {"city":"Paris"}
    → {"city":"Paris","tempC":18,"summary":"clear"} 1ms
✔ finish · 2 turns · 20 in / 10 out · 389ms

contains $schema:             false
contains additionalProperties: false

See Providers.

Patterns worth copying

A shared agent in a server route

lib/agent.ts
export const agent = new Agent({
  name: 'support',
  instructions: 'You are a support agent for Acme.',
  model: openrouter('anthropic/claude-opus-5'),
  tools: [lookupOrder],
})
app/api/chat/route.ts
import { agent } from '@/lib/agent'

export async function POST(request: Request) {
  const { message, userId } = await request.json()

  const result = await agent.run(message, {
    sessionId: userId, // history loads and saves itself
    signal: request.signal, // cancels if the client disconnects
    timeoutMs: 60_000,
  })

  return Response.json({ reply: result.output })
}

One agent instance, created once, serving concurrent requests — safe because Agent holds no mutable state. Give it a session store and the route stops carrying transcripts entirely; see Sessions.

Per-user model tiers

const model = user.isPro
  ? anthropic('claude-opus-5')
  : anthropic('claude-haiku-4-5')

const result = await agent.clone({ model }).run(prompt)

Guarding an autonomous job

const result = await agent
  .clone({ maxTurns: 5, onToolError: 'throw' })
  .run(task, {
    timeoutMs: 120_000,
    onEvent: (event) => {
      if (event.type === 'tool.end' && event.isError) logger.warn(event)
    },
  })

if (result.stopReason !== 'finish') {
  throw new Error(`agent did not converge: ${result.stopReason}`)
}

Next

On this page