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/.env1 — Hello, agent
The minimum viable agent: instructions, a model, one question. No tools, no streaming, no session.
pnpm example:helloimport { 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_qualityThe 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-flightThe 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:servercurl -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 turnsThe 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:structuredFour 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
Ticketfrom 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_customerruns first, then the typed object. maxOutputRetries: 0against a model that will not comply, so you can see whatInvalidOutputErroractually 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 turnThat 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:guardrailsRuns 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 underonToolError: '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 turnSee Guardrails.
9 — Handoffs
pnpm example:handoffsAlso 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
agentPathshows the route. - A
filterthat 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, billingThree 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 realThe tool pack, in four acts:
- Five tools on an agent constructed with nothing but a name and a model — and
an
evalattempt againstcalculatethat 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/passwdgetting a tool result rather than a file. http_fetchrefusing169.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 offlineThe native transports, and the part that matters in production:
- The exact Messages API body the Anthropic provider builds —
systemhoisted to a top-level field,max_tokenssupplied because the API demands one, tools underinput_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
$schemanoradditionalProperties, 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: falseSee Providers.
Patterns worth copying
A shared agent in a server route
export const agent = new Agent({
name: 'support',
instructions: 'You are a support agent for Acme.',
model: openrouter('anthropic/claude-opus-5'),
tools: [lookupOrder],
})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}`)
}