docs← Back to article

Markdown for LLMs

Build your first agent

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# 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](/agent-engineering/integration-patterns/).

## 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_KEY` in the environment.
- The example directory `examples/first-agent/` from the [example
  bundle](/agent-engineering/agent-engineering-examples-0.5.0.zip).

```bash
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).

## 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](/agent-engineering/files/first-agent/period-tool.mjs)):

```js
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.

## 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](/agent-engineering/files/first-agent/check-tool.mjs)
runs three cases from
[expected.json](/agent-engineering/files/first-agent/expected.json):

```bash
node check-tool.mjs
```

```text
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:

| 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](/agent-engineering/handle-answers/) covers every status.

## Step 3: the agent

[agent.mjs](/agent-engineering/files/first-agent/agent.mjs) wraps the tool for the
Claude Agent SDK and gives the model four instructions:

```js
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:

```bash
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

- 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](/agent-engineering/evaluations/).

## 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@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](/agent-engineering/task-guides/).
- **An agent that finds the question itself:** use the Arxo tools over
  MCP; see [Connect and discover](/agent-engineering/connect-and-discover/).

## Validation record

| 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](/agent-engineering/integration-patterns/)
Next: [The agent contract](/agent-engineering/agent-contract/)