Skip to content
docs
Arxo ↗

Build your first agent

For LLMs7 sections

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.

  • Node 20 or later.
  • Access to a Claude model for the agent loop: a logged-in Claude Code installation or an ANTHROPIC_API_KEY in the environment.
  • The example directory examples/first-agent/ from the example bundle.
Terminal
cd files/first-agent
npm install

The 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).

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):

JavaScript
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), grounds and result_hash; an empty list adds a note that it is not an answer.

Before any model is involved, check that the tool returns what the engine returns. check-tool.mjs runs three cases from expected.json:

Terminal
node check-tool.mjs
Output
PASS complete
PASS length-missing
PASS weekday-end

Status: 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:

CaseInputTool result
completeevent 2026-03-07, 14 days, law read at 2026-09-17COMPUTED, end date 2026-03-23, ground TagesfristEnde (section 187), hash sha256:8078d57f…
length-missingevent 2026-03-07, no lengthCOMPUTED, 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.

agent.mjs wraps the tool for the Claude Agent SDK and gives the model four instructions:

JavaScript
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:

Terminal
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.

  • It never counts days: it has no tool to count with.
  • It never sees frist_ende, frist_dauer_tage or 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.

  • 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@version and read its relations with unfold or 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.
Show commands, versions and results
WhatCommandResult
Domain tool, three casesnode check-tool.mjs3 PASS, offline
SDK behavior used on this pagecollect and truth without the length fact; no deadline policyempty list with COMPUTED; NEITHER; without a policy NON_EXECUTABLE with an error issue coded MISSING_POLICY
Agent loopnode 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.