Build your first agent
This page builds a working agent in about eighty lines. A person asks
when a statutory period ends; the agent answers with the date, the
sections it rests on and a hash anyone can replay. The model never
counts a day: it calls one domain tool, period_end, and the tool asks
the canon de.bgb.fristen@0.1.0 — the period rules of the German Civil
Code — through the engine running locally in your process.
The model does not see Arxo. It sees a tool named after the task, which is the first pattern of Choose an integration pattern.
What you need
Section titled “What you need”- Node 20 or later.
- Access to a Claude model for the agent loop: a logged-in Claude Code
installation or an
ANTHROPIC_API_KEYin the environment. - The example directory
examples/first-agent/from the example bundle.
cd files/first-agentnpm installThe four packages are pinned to exact versions: @arxo/law 0.3.3 (the
engine and the SDK), @arxo/canon-bgb-fristen 0.1.5 (the canon, so
open works offline), @anthropic-ai/claude-agent-sdk 0.3.289 (the
agent loop) and zod 4.6.5 (the tool’s input schema).
Step 1: the domain tool
Section titled “Step 1: the domain tool”The tool is plain application code; it holds no model and no rule. It turns three arguments into typed facts, asks one question, and returns the engine’s answer in the application’s own words (period-tool.mjs):
export function periodTool(canon) { const sections = Object.fromEntries( canon.unfold('frist_ende', { depth: 1 }).tree.producers .map((rule) => [rule.name, rule.anchors?.[0]?.locator ?? null]), ); return async function periodEnd({ event_date, length_days, law_date }) { const answers = [{ predicate: 'frist_ereignis', args: ['frist', event_date] }]; if (length_days !== undefined) { answers.push({ predicate: 'frist_dauer_tage', args: ['frist', `${length_days} calendar_day`] }); } const answer = await canon.collect('frist_ende', ['frist', '?end'], { legalTime: law_date, timezone: 'Europe/Berlin', deadlinePolicy: POLICY, answers, }); // ...status, end dates, grounds with sections, result hash };}Three choices in it carry the whole method:
- The date of law is an argument. The tool cannot run without it, and nothing fills it in.
- A missing fact stays missing. When the user gave no length, the tool sends no length fact; it does not guess one.
- The status always comes back. The result has
status,end_dates(a list, possibly empty),groundsandresult_hash; an empty list adds a note that it is not an answer.
Step 2: check the tool without a model
Section titled “Step 2: check the tool without a model”Before any model is involved, check that the tool returns what the engine returns. check-tool.mjs runs three cases from expected.json:
node check-tool.mjsPASS completePASS length-missingPASS weekday-endStatus: ran locally (node check-tool.mjs, Node 20.20.2, @arxo/law
0.3.3, de.bgb.fristen@0.1.0), offline.
The two cases that matter:
| Case | Input | Tool result |
|---|---|---|
complete | event 2026-03-07, 14 days, law read at 2026-09-17 | COMPUTED, end date 2026-03-23, ground TagesfristEnde (section 187), hash sha256:8078d57f… |
length-missing | event 2026-03-07, no length | COMPUTED, end_dates: [], no grounds, note “do not compute one” |
Fourteen days from Saturday 7 March land on Saturday 21 March; the canon’s deadline policy moves the end to Monday 23 March. The model is not asked to know that.
The second row is the reason the tool exists. The evaluation completed;
it found no end date because a fact is missing. COMPUTED here does
not mean “here is your answer”: it means the question was evaluated.
Read together with the empty list, it means “ask for the length”. The
same question asked with truth instead of collect returns
NEITHER, and in both forms the answer’s missingInputs is empty — the
engine does not always name the missing fact, so the tool says it
plainly. Read answers covers every status.
Step 3: the agent
Section titled “Step 3: the agent”agent.mjs wraps the tool for the Claude Agent SDK and gives the model four instructions:
const instructions = `You help people work out statutory periods under German civil law.- Get every end date from the period_end tool. Never count days or move dates yourself.- The tool needs the date at which the law is read. If the user did not state it, ask for it; do not assume today.- If the tool returns no end date, say the law does not derive one from the facts given and name the missing fact. Do not guess.- When you give a date, name the sections in the tool's grounds and quote the result hash.`;
for await (const message of query({ prompt, options: { systemPrompt: instructions, model: 'claude-opus-5-5', tools: [], // no built-in tools: only period_end mcpServers: { periods: createSdkMcpServer({ name: 'periods', version: '1.0.0', tools: [periodEndTool] }) }, allowedTools: ['mcp__periods__period_end'], maxTurns: 6, },})) { /* print the final result, write the trace */ }tools: [] removes every built-in tool, so the model has nothing to
count with except period_end. --out run.json writes a trace: the
prompt, every tool call with its input and result, and the final reply.
Run it with the complete facts, then without the length:
node agent.mjs --out run-complete.json \ "A 14-day period starts with an event on Saturday 7 March 2026. Read the law as of 17 September 2026. When does the period end?"node agent.mjs --out run-missing.json \ "A period starts with an event on 7 March 2026. Read the law as of 17 September 2026. When does it end?"Status: NOT RUN for the model loop — the run for this page could not
authenticate to a Claude model. The tool underneath ran (step 2).
Expected behavior to check in the traces: in the first run one
period_end call with all three arguments and a reply that gives
23 March 2026, section 187 and the hash; in the second, a call without
length_days (or none) and a reply that asks for the length instead
of giving a date. A third run without a date of law should produce a
question for that date and no tool call with an invented one.
What the model never does
Section titled “What the model never does”- It never counts days: it has no tool to count with.
- It never sees
frist_ende,frist_dauer_tageor a deadline policy; the vocabulary of the canon stays inside the tool. - It never turns an empty list into a date.
These are properties of the code, not of the prompt: the instructions ask, the tool and the empty tool list enforce. If the model ignores an instruction, the worst it can do is answer without calling the tool — which an evaluation catches by checking that every date in the reply came from a tool result; see Evaluate, debug and upgrade.
Make it yours
Section titled “Make it yours”- Another question of the same canon: add one tool per question. Keep each tool narrow; a tool that takes a free-form predicate hands the canon’s vocabulary back to the model.
- Another canon: open it by
name@versionand read its relations withunfoldor its question cards before writing the tool. - A multi-step task: if the canon publishes a task guide, follow it instead of inventing steps; see Follow a task guide.
- An agent that finds the question itself: use the Arxo tools over MCP; see Connect and discover.
Validation record
Section titled “Validation record”Show commands, versions and results
| What | Command | Result |
|---|---|---|
| Domain tool, three cases | node check-tool.mjs | 3 PASS, offline |
| SDK behavior used on this page | collect and truth without the length fact; no deadline policy | empty list with COMPUTED; NEITHER; without a policy NON_EXECUTABLE with an error issue coded MISSING_POLICY |
| Agent loop | node agent.mjs … | NOT RUN: no model authentication in this run |
Versions: Node 20.20.2, @arxo/law 0.3.3, @arxo/canon-bgb-fristen
0.1.5, @anthropic-ai/claude-agent-sdk 0.3.289, zod 4.6.5.
Previous: Choose an integration pattern Next: The agent contract
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.