just-another-sdk

Agents

Configuration, runs, and the three kinds of state.

Three kinds of state

Most agent code becomes unmaintainable for one reason: configuration, per-run state, and persisted history get mixed into a single mutable object. This SDK keeps them apart.

HoldsLifetime
Agentname, instructions, model, tools, limitsCreated once, shared across every request
RunStatemessages, turn count, usage, stepsOne run
Sessionpersisted conversationAcross runs — see Sessions

Because an Agent is immutable, one instance is safe to create at module scope and share across concurrent users:

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

export async function POST(request: Request) {
  const { message, userId } = await request.json()
  // Safe under concurrency: nothing here mutates the agent.
  const result = await supportAgent.run(message, { sessionId: userId })
  return Response.json({ reply: result.output })
}

Configuration

Prop

Type

Dynamic instructions

Pass a function when the prompt depends on something known only at run time. It is resolved once per run, so the agent itself stays immutable:

const agent = new Agent({
  name: 'assistant',
  model: openrouter('anthropic/claude-opus-5'),
  instructions: () =>
    `You are a helpful assistant. Today is ${new Date().toDateString()}.`,
})

Do not interpolate untrusted user input into instructions. Anything in the system prompt carries authority; user content belongs in the user turn, which is where run(input) puts it.

Running

const result = await agent.run('What is the weather in Paris?', {
  sessionId: 'user_123', // continue a persisted conversation
  signal: controller.signal, // cancel the run
  timeoutMs: 30_000, // deadline for the whole run
  maxTurns: 4, // override the agent's ceiling for this run
  onEvent: consoleTracer(), // observe it
  metadata: { userId: 'u_123' },
})

sessionId and messages are alternatives, not companions — use messages to manage the transcript yourself, sessionId to let the SDK do it. Passing both throws. See Sessions.

The result

result.runId // 'run_m9x2k1p_a7f3z9' — appears in every event
result.output // the answer
result.text // raw final assistant text
result.stopReason // 'finish' | 'max_turns'
result.messages // full conversation, ready to pass back in
result.steps // one entry per turn, each naming the agent that took it
result.usage // summed across every model call
result.turns // number of model calls
result.durationMs
result.modelId // the model that served the final turn
result.agentName // the agent that produced the answer
result.agentPath // ['triage', 'billing'] — every agent that acted

agentName and agentPath differ only when the run handed off; without one, the path is [agent.name].

run() resolves only when the agent produced something. Cancellation, provider failures, and (under onToolError: 'throw') tool failures reject instead — so a RunResult in hand always means you have an answer to work with.

Stop reasons

if (result.stopReason === 'max_turns') {
  // The model was still calling tools when the ceiling was reached.
  // The last assistant text is returned, but the task may be unfinished.
}

Always branch on this in an autonomous pipeline. In an interactive chat it matters less, because the user will simply ask again.

Reuse and specialisation

clone() returns a new agent with some configuration replaced, leaving the original untouched. This is the right way to specialise a shared agent per request:

// A cheaper model for free-tier users
const cheap = supportAgent.clone({
  model: openrouter('anthropic/claude-haiku-4-5'),
})

// Extra capability for an admin
const admin = supportAgent.withTools(issueRefund, deleteAccount)

// A router built after its specialists exist
const router = triageAgent.withHandoffs(billing, technical)

// Stricter ceiling for a background job
const batch = supportAgent.clone({ maxTurns: 3, onToolError: 'throw' })

Inspecting an agent

agent.name // 'support'
agent.modelId // 'anthropic/claude-opus-5'
agent.toolNames // ['lookup_order', 'calculate', …, 'transfer_to_billing']
agent.handoffNames // ['billing']
agent.toConfig() // the resolved configuration, read-only

toolNames is everything the model is shown: your own tools, the automatic built-ins, and the transfer_to_* tool each handoff contributes.

Errors at construction time

The constructor validates eagerly, so mistakes surface where you wrote them rather than on the first production request:

new Agent({ name: '', model }) // ConfigurationError: An agent needs a name.
new Agent({ name: 'a', model: null }) // ConfigurationError: no valid model
new Agent({ name: 'a', model, maxTurns: 0 }) // ConfigurationError: maxTurns must be at least 1
new Agent({ name: 'a', model, tools: [a, a] }) // ConfigurationError: Two tools are both named "…"

Functional shorthand

For a one-shot call where an agent instance would not be reused:

import { run } from 'just-another-sdk'

const result = await run(
  { name: 'classifier', model: openrouter('anthropic/claude-haiku-4-5') },
  'Classify this ticket as bug, feature, or question: …',
)

Next

On this page