Skip to content

M2-37: Guide "connect your own agent" with a check run

Status: ready | Depends on: M2-22, M2-30, M2-31 | Milestone: M2

Goal

After this ticket the documentation site has the developer guide "Connect your own agent" (docs/guides/connect-agent.md, English like the site) with the ten steps of 11 "Guide: connecting your own agent in ten steps", and the guide is executable: one Go test (services/server/test/guide) reads the page, starts a fresh server, sends every request block as the owner or as a person, runs every command block (key generation, the example adapter in sdk/python/examples/guide/ on the Python SDK of M2-29, the example's FakeServer tests), compares every answer and every output line with the text and every shown file with the file in the repository, and fails at the first step that differs from the text. The example adapter connects through the gateway with a consumer, reports its capabilities in the heartbeat, registers a control command and a model command with autocomplete, accepts invocations only after the SDK's token check and its own actual-mode comparison, fetches the context with trust marks, starts runs, fills the live box, answers, finishes with usage, handles a follow-up, a stop, a question, an approval card and (when the base branch already accepts attachments) a file, and survives a restart between acceptance and start. This is exit criterion 7 of milestone M2 ("The guide check run is green") and the proof for the owner's requirements 8 and 21 (PLAN 18.1) that an outside developer connects a generic agent through the public API alone (09 1 point 2, 11 1 point 3, ADR 0022, P-18). Why: a guide that nobody runs drifts away from API, SDK and server unnoticed, and the first developer who follows it hits a broken step; executing the text itself is the only way the text stays true.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Test doubles", "End to end"); section 8 "M2 Agent platform, extensions and terminal": Goal (P-18), Scope ("a guide for custom agents"), Out of scope ("Files in agent invocations and voice messages (M3)", "user interface (M4)"), Exit criteria 1, 4 and 7, Test strategy, Red team (first paragraph), Risks; block "agents and extensions" with the rows M2-29, M2-30 and M2-31 (what exists), M2-37 (this ticket), M2-09 to M2-20 and M2-22 (the server features the guide exercises); M2-14 (SR9); M3-07 (attachments); M4-28 and M6-04 (the admin screens that later call the same operations); 18.1 rows 8 and 21; section 19 P-09, P-10, P-11, P-18.
  • Dependencies: the header names M2-22, M2-30 and M2-31. Implied through them and therefore not in the header: M2-29, M2-27, M2-26, M2-24, M2-23, M2-20, M2-19, M2-18, M2-17, M2-16, M2-15, M2-14, M2-13, M2-12, M2-11, M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M2-01 and the M1 tickets M1-01 to M1-29, M1-31 to M1-37 (M1-37: setup path, used only as the merged suite of M2-31 uses it, Design note 10), M1-44 to M1-46, M1-48 to M1-58, M1-60 to M1-66, M1-68, M1-69 and M1-71. Not in the closure although touched: M3-07 (attachments, Design note 11).
  • M2-29, M2-30 and M2-31: read their briefs (docs/briefs/M2-29-python-sdk.md, M2-30-typescript-sdk.md, M2-31-agent-conformance-suite.md), their plan rows, the merged code (sdk/python with its README and API reference page, sdk/typescript, services/server/test/agents, sdk/python module testing) and their reports. Their merged names win (Boundaries).
  • docs/spec/11-extensions.md: 1 "Principles" (point 3: no shortcut for own extensions); 2 "Agent or app"; 11 "SDK design" (11.1, 11.2 module table, 11.3); 12 "Versioning and compatibility" (compatibility matrix in sdk/README.md); 13 "Guide: connecting your own agent in ten steps" (the outline this guide implements; its bold step titles are checked by the test); 16 (sdk.*).
  • docs/spec/09-agents.md: 1 "Principles"; 2.1 "Records" (row "Access"); 2.2 "Features" (agent_operator_config.v1, heartbeat capabilities); 2.3; 2.4 "Tool class"; 3.2 "Origin (messages.origin)"; 3.3 "Channel switches"; 3.4; 4.1 to 4.10 (connection and cursor, events to the adapter, invocation token with the adapter checks 1 to 10, 4.5.1, context with trust, the operations table of 4.7, heartbeat, 4.9 "Order in the adapter: commitment first, then runtime", 4.10 "Usage at the end of a run"); 5.1, 5.2, 5.3; 7 "Follow-ups during a run"; 8 "Stop"; 10.2, 10.4, 10.5 (commands and autocomplete); 11.1 to 11.6 (components, approval buttons, questions); 15 "Reliability"; 16.1 "Test doubles" and 16.2 "Required scenarios" (AG-08, AG-09, AG-11, AG-12, AG-28); 19 (R-AG-07, R-AG-24); 21.
  • docs/spec/08-identity.md: 4 "Initial installation"; 6.2 "Step-up flow"; 7.3 "DPoP"; 12 "API tokens for agents, apps and tools" (binding: DPoP required while agent is in auth.dpop_required_for).
  • docs/spec/10-backend-policy.md: 7 "Adapter check (I3)" (key_type = unknown counts as a mismatch); 8 "Expected and reported"; 12.1 "Recording".
  • docs/spec/06-api.md: 3.1 "DPoP"; 3.3 "Step-up"; 5 "Errors" (point 3, errors[].code not_enabled); 7 "Idempotency"; 8 "Concurrency" (ETag, If-Match, 412 version_conflict, 428 precondition_required); 16 "Endpoints".
  • docs/spec/04-sync-protocol.md (core contract, read only): 15 "Agent adapters" (15.1 to 15.5, 15.7).
  • docs/spec/02-architecture.md: 6 "Code layout" (test/** is the component testsupport; identitytest; the list "Places that carry the product name").
  • docs/spec/23-quality.md: 3 "Component contract" (row "Python project"), "Documentation in code" (ruff D, convention google); 4 "CI" (path selection of components).
  • docs/spec/19-security.md: SEC-LOG-2, SEC-LOG-4, SEC-T10. docs/security/threat-model.md: T-TK-02, T-TK-05, T-AI-01, T-AI-03. docs/RISKS.md: RK-09, RK-18. docs/OPEN-QUESTIONS.md: OQ-17 (no publication of the SDK).
  • docs/api/openapi.yaml: operations createSpace, createChannel, createGrant, createAgent, getAgent, createToken, getChannel, setChannelOperator, getChannelBehavior, putChannelBehavior, listChannelCommands, autocompleteCommand, invokeCommand, createThread, createMessage, getMessage, getRun, listThreadRuns, cancelRun, createInteraction (the requests of people) and sendHeartbeat, registerCommand, answerAutocomplete, getRunContext, startRun, resumeRun, renewInvocationToken, patchRun, awaitRunInput, finishRun, respondToInteraction, updateComponent, listServedChannels (the agent side, used through the SDK); createUpload, uploadPart, completeUpload (Design note 11); schemas AgentCreate, Agent, AgentRuntimeStatus, ApiTokenCreate, ApiTokenCreated, ApiToken, GrantCreate, ChannelView, ChannelOperator, OperatorSet, ChannelBehavior, ChannelBehaviorPut, ThreadCreate, ThreadCreated, MessageCreate, Message, Attachment, File, CommandList, CommandRegister, CommandOption, CommandOptionValue, CommandInvoke, CommandInvocation, AutocompleteQuery, AutocompleteResult, CommandChoice, InteractionCreate, Interaction, ComponentCreate, Heartbeat, BillingEvidence, RunStart, RunPatch, RunFinish, CostReport, RunCancel, RunSummary, Run, Problem (x-problem-codes), FieldError. docs/api/asyncapi.yaml: events agent.invocation, agent.followup, interaction.created, command.invoked, run.cancel_requested, command.autocomplete_requested, op command.autocomplete_result.
  • docs/operations/configuration.md: auth.dpop_required_for, auth.api_token_max_ttl, auth.step_up_max_age, agent.invocation_token_ttl, agent.ack_timeout, agent.presence_timeout, agent.heartbeat_persist_interval, agent.cancel_grace, command.autocomplete_timeout, channel.history_backfill_limit_default, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, sdk.http_timeout, the ratelimit.* keys.
  • ADRs 0013, 0022, 0023, 0026, 0038, 0044, 0045, 0053, 0054. docs/DECISIONS.md: D-W-54, D-W-100, section 10 (row format D-M2-37-<n>).
  • Briefs whose interfaces the guide exercises: docs/briefs/M2-02-channel-behavior-config.md (If-Match on settings_version), M2-07-agent-consumers.md, M2-09-run-lifecycle.md (start, finish, cancel, the costs rule of Design note 6d), M2-10-run-document-live-box.md, M2-11-run-followups.md, M2-13-run-context.md, M2-15-components-interactions.md (cards, approval buttons, resume by interaction), M2-16-slash-commands.md, M2-17-command-autocomplete.md, M2-19-agent-messages.md, M2-20-heartbeat-presence.md (presence in getAgent), M2-22-costs.md, M2-23-generated-clients.md (layout of sdk/python, distribution bothy-sdk, import package bothy_sdk, VERSION); M1 briefs M1-05-server-skeleton.md (testsupport), M1-12-postgres-migrations.md (pgtest), M1-17-api-server-stubs.md (api/operations.yaml), M1-33-identity-devices-dpop.md (identitytest), M1-44-identity-api-tokens.md, M1-48-authz-api.md, M1-51-agents-records.md, M1-53-chat-channels.md (forum channels), M1-56-messaging-send.md (the not_enabled fields).
  • Repository files: mkdocs.yml (nav, pymdownx.highlight renders the title of a fence), tools/docs/mkdocs_markdown_structure.py, tools/docs/mkdocs_repo_links.py, tools/ci/components.py and tools/ci/tests/test_components.py, sonar-project.properties, sdk/README.md, sdk/python/README.md, docs/api/README.md, docs/quality/gates.md.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. The guide covers the ten steps of 11 13 in that order, each under a heading whose title is the bold title of the step in 11 13 (PLAN row M2-37).
  2. One automated test runs every step against a fresh server and fails when a step differs from the text: a request or an answer, an output line, a shown file, a step title, the order of the steps or the SDK version pin (PLAN row M2-37, exit criterion 7).
  3. The example adapter uses only the public API through the Python SDK, never a private channel and never the generated core where the hand layer offers the operation (09 1 point 2, 11 1 point 3, 11 11.2, ADR 0022, ADR 0026); it never calls a model (PLAN 3 point 5).
  4. The agent token is bound to the adapter's key (DPoP) as auth.dpop_required_for requires by default, lives only in a file readable by the adapter, and never appears in any output (08 12, T-TK-02).
  5. The adapter processes an event with a token only after the SDK's checks of 09 4.5 and its own actual-mode comparison (I3, 10 7), acknowledges after storing it in its inbox (09 4.3), starts its runtime only after 200 on startRun (R-AG-07), and hands context with trust unverified to the runtime only as marked data (09 4.6, ADR 0054, T-AI-01).
  6. Output and logs of the example contain no message content, no prompt, no token and no secret (SEC-LOG-2, SEC-LOG-4).
  7. The example passes the scenarios AG-08 (adapter side), AG-09, AG-11, AG-12 and AG-28 of 09 16.2 against the SDK's FakeServer (09 16.1, step 10 of 11 13).
  8. A restart between acceptance and start resumes the run from the inbox and starts it exactly once (09 4.9, 09 15, RK-18).

Scope

Paths are relative to the repository root. This ticket creates no migration and no new top-level make target; the check runs inside make test (component server).

Create:

  • docs/guides/connect-agent.md: the guide (English), structure and blocks per Design notes 2 to 9.
  • sdk/python/examples/README.md (English: what the examples are, how to run them, that the guide shows them and the test runs them).
  • sdk/python/examples/guide/: keygen.py, agent.py, connect.py, heartbeat.py, commands.py, invocation.py, runtime.py, run.py, interactive.py and tests/test_agent.py (Design notes 8 and 9). No other file in this directory except an empty package file if one turns out to be required.
  • services/server/test/guide/: doc.go (package doc), parse.go (guide parser), validate.go (static checks), match.go (JSON and output matching), run.go (executor), parse_test.go, validate_test.go, match_test.go, mutation_test.go, guide_test.go and README.md (English: what the check proves, prerequisites uv and a Docker-compatible runtime, how to run it, how to read a failure).

Change:

  • mkdocs.yml: nav section Guides with the entry Connect your own agent: guides/connect-agent.md, placed after API.
  • sdk/python/pyproject.toml: the examples in lint, type check, tests and coverage (Design note 13).
  • tools/ci/components.py and tools/ci/tests/test_components.py (Design note 14).
  • docs/spec/11-extensions.md 13, docs/spec/09-agents.md 2.1, docs/spec/02-architecture.md 6 (Design note 15).
  • docs/api/README.md, sdk/README.md, sdk/python/README.md: a link to the guide; docs/quality/gates.md row test (Design note 15).
  • sdk/python/src/bothy_sdk/...: only if the merged hand layer lacks a public function a step needs (Design note 8, last bullet), with tests, docstrings and a CHANGELOG line.
  • services/server/test/harness or the helpers of services/server/test/agents: additive changes only, only if the runner needs a helper they lack (Design note 10).
  • docs/DECISIONS.md section 10: rows D-M2-37-1 and following (Design note 16).
  • services/server/CHANGELOG.md, sdk/python/CHANGELOG.md and the root CHANGELOG.md under "Unreleased".

Out of scope

  • A runnable TypeScript example: the guide names the TypeScript SDK and links its README and reference (Design note 2); the TypeScript SDK itself is M2-30.
  • The admin screens for agents, tokens, grants and channels (M4-28, M6-04); they call the same operations the guide shows.
  • Files in the guide while the base branch does not accept attachments (Design note 11); the file service and attachments themselves (M3-03 to M3-07).
  • The conformance suite AG-01 to AG-30 against the real server (M2-31), the SDK modules and their own tests (M2-29), the reference adapters (M2-33 to M2-35), load tests (M2-36).
  • Publication of the SDK packages and their final names (OQ-17, V1-06); the release chain of the SDK components (M7).
  • Webhook and REST polling as transports for the example (09 4.2); the guide uses the gateway as 11 13 step 4 says.
  • Changes to server behaviour; a deviation the check run finds in the server is fixed in its module only if it contradicts the specification, and is reported (Design note 1).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-37-agent-guide; one writer per worktree.
  • Conventional Commits with the module or component as scope (for example docs(guide): ..., test(server): ..., feat(sdk-python): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a package and never reserve a name at PyPI or npm (OQ-17).
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. Without uv the check run fails; it never skips.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • The runner signals an adapter only through the process group it created for that adapter, and its temporary directories come from t.TempDir().
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules; the locked Python packages of sdk/python count as pinned tools.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for M2-29, M2-30 and M2-31 and the M2 tickets in their closure: their merged names (SDK modules and functions, the reference page, harness functions, file names) win over the expected names here; list each difference.
  • No model call and no third-party service in any test or example: the example's runtime is the rule set of Design note 9. The only server is the fresh server the test starts.
  • The check run never writes into the working tree except the ignored sdk/python/.venv that uv manages; every adapter file lives in the temporary adapter directory.
  • No string in a committed file matches the API token format of 08 12 (bothy_<kind>_...); the guide shows a token only as the placeholder of Design note 4.
  • Time only through real waits on events; no fixed sleeps. Every wait ends at the test deadline (t.Deadline()), never at a number written into the test.

Design notes

  1. Start check. Run make check on the fresh worktree; if it is red, stop with Verdict: BLOCKED base branch red and the failing gate. Confirm that the base branch has M2-29 (the SDK modules auth, gateway, invocation, runs, components, commands, outbox, heartbeat, testing of 11 11.2 and the API reference page in mkdocs.yml), M2-30 (sdk/typescript with README and reference) and M2-31 (services/server/test/agents running the Python fake agent against the real server). Then determine one fact and record it in the report: whether MessageCreate.attachment_file_ids is enabled (M3-07, Design note 11). When the check run shows that the server differs from the specification, fix it in the owning module only if the specification is unambiguous, with a red test first, and report it; if the fix would touch the core contract, stop with Verdict: BLOCKED <reason>.

  2. The page. Ruling for docs/DECISIONS.md: the guide lives at docs/guides/connect-agent.md (English file name like the other pages, English text like the site), in a new nav section Guides; it addresses a developer who connects an own adapter (01, role "Bot developer"). Sections in this order: # Connect your own agent with a short introduction; ## Placeholders (the table of Design note 4); ## Prerequisites; ## Step 1: Create the agent to ## Step 10: Test and operate, each title exactly the bold title of the step in 11 13 without its final period; ## How the repository checks this guide (three sentences: which test, what it compares, how to run it); ## Next (links: 09, 11, sdk/README.md with the compatibility matrix, the Python API reference page of M2-29, sdk/typescript/README.md). The guide's code is Python only; TypeScript gets one sentence in step 4 naming the package and linking sdk/typescript/README.md and its reference. Ruling for docs/DECISIONS.md: one executable path keeps the check exact; the PLAN row names sdk/python/examples. No Mermaid block (every fence must be executable, note 3); link the sequence diagram of 09 4.9 instead. Every owner step shows the API operation; one sentence says that the admin screens of app and console call the same operations. Ruling for docs/DECISIONS.md: the API is the path a developer can follow today and the one every screen uses.

  3. Block grammar (the contract between text and test; Ruling for docs/DECISIONS.md). Every fenced block of the page starts at column 0 with three backticks, a language and a title="..." attribute; an untitled block, a block with an unknown title form and an indented titled block fail the check. Kinds:

    • Request: language http, title <Actor>: <operationId> with actor Owner, Alex or Sam. First line <METHOD> /api/v1/<path> HTTP/1.1 (the HTTP lexer of the site needs the version); then header lines (only Content-Type: application/json and If-Match); then a blank line and an optional JSON body. The method and the path must equal the operation of that operationId in docs/api/openapi.yaml, with any placeholder matching any path parameter. There is no actor Agent: the agent acts only through the example program.
    • Response: language json, title Response <status>, directly after a request block. The status must be equal and the body is an expected subset (note 5). A request without a response block must answer with a 2xx status.
    • Command: language bash, title Shell: <text>. Run with bash -euo pipefail -c in the repository root after substitution; it must exit 0. Its stdout is recorded for a following output block.
    • Adapter start: language bash, title Adapter: start, exactly one command line; starts the adapter as a background process in its own process group. A start while a previous adapter may still run fails the static check, unless that previous start carried --stop-after accept (it stops by itself, note 7 step 10) or an Adapter: stop block came after it; the runner then waits for the previous process to exit and requires exit code 0 and all of its stdout lines matched.
    • Adapter stop: language bash, title Adapter: stop, body exactly # Ctrl+C in the adapter's terminal. The runner sends SIGINT to the adapter's process group and requires exit code 0.
    • Output: language text, title Output. After a Shell: block: the complete stdout, line by line. Otherwise: the next stdout lines of the running adapter, consumed in order.
    • File: language python, title the repository path of a file under sdk/python/examples/guide/. The block content plus a final newline equals the file byte for byte; every file there (note 8) appears in exactly one block.
    • Pin: language toml, title pyproject.toml (excerpt). It parses as TOML, and project.dependencies contains exactly one entry for the SDK, bothy-sdk==<content of sdk/python/VERSION>. The prose says that until the packages are published (OQ-17) the SDK comes from a checkout of the repository.

    Fenced blocks stand only in ## Prerequisites and the ten step sections; a fence in any other section fails the check, so the maintainer section describes the grammar in prose.

  4. Placeholders, captures and secrets. A placeholder is {name} with name matching ^[a-z][a-z0-9_]*$, or {secret:<file>} with a file name matching ^[a-z0-9][a-z0-9._-]*$. Bound by the runner before the first block, and listed in the table ## Placeholders (the check requires that the table's first column holds exactly these names): {server_url} (address of the instance; in the guide's prose an example like https://chat.example.com, in the test the public URL of the fresh server), {adapter_dir} (a folder only the adapter may read; in the test a t.TempDir() with mode 0700), {alex_id}, {sam_id} (the people of the preconditions), {new_uuid} (a fresh UUIDv7 at each occurrence, for client_msg_id and client_interaction_id), {expires_at} (RFC 3339 in UTC; in the test the start time plus half of the effective auth.api_token_max_ttl of the test server). Every other name is captured exactly once, from a response block or an output block, before its first use; a later occurrence compares with the bound value; a use before the capture and a second capture fail the check. In a JSON body or an expected body, a string that is exactly "{name}" stands for the bound JSON value with its type (so "{history_backfill_limit}" becomes a number); everywhere else substitution is textual, and a value substituted into a bash block must match ^[A-Za-z0-9._:/@+-]+$ and is single-quoted. {secret:<file>} may appear only as a string in a response block: the runner captures the string, writes it to {adapter_dir}/<file> with mode 0600, never logs or compares it and redacts it from every error message.

  5. Matching. JSON: an expected object matches when every expected member exists in the actual object and matches; an expected scalar matches an equal scalar of the same JSON type; an expected array matches when each expected element matches exactly one distinct actual element, order ignored, extra actual elements allowed; an element that matches more than one actual element fails as ambiguous (the guide then adds a distinguishing member). Output: each expected line must equal the next actual stdout line after substitution; an unbound placeholder in a line captures \S+; a differing line fails at once with step, expected line and actual line; a process that ends before all expected lines arrived fails; a process whose stdout has lines no output block expects fails when it ends. Every failure names the step (Step <n> or the section), the block title and, for JSON, the JSON Pointer of the first difference; nothing after the failing block runs.

  6. Actors and authentication. Owner is the first owner of the fresh server (Robin); before every Owner request the runner performs a fresh step-up with the device key, as the admin screens do before an admin action (08 6.2, auth.step_up_max_age), so no owner block can fail with 401 step_up_required. Alex has trigger and approve_actions on space Support, Sam only write (granted in "Prerequisites"). Each person's requests carry their session token with DPoP and the server nonce and an Idempotency-Key where 06 "Idempotency" asks for one, all added by the harness client; the guide says in prose that the client adds authentication, DPoP and Idempotency-Key. A person's request never carries an invocation token.

  7. Scenario. The blocks per section, in this order; every response block names at least the members listed here, and the output lines follow the format Step <n>: <text> of note 8. The exact English texts are the build session's, but each line below appears.

    • Prerequisites: Owner: createSpace (name Support, captures {space_id}); Owner: createChannel (kind forum, name Tickets, captures {channel_id}); Owner: createGrant for Alex (subject_kind user, target space, level trigger, capabilities approve_actions); Owner: createGrant for Sam (level write); Owner: getDiscovery (GET /api/v1/instance/discovery HTTP/1.1) with Response 200 capturing instance_id as {instance_id}. Prose: an instance with an owner (link ../operations/install-compose.md) and the two people; the owner hands the instance ID to the developer, who configures it in the adapter (step 4).
    • Step 1: Owner: createAgent with handle lotse, display name Lotse, adapter_kind custom and a description; Response 201 with id {agent_id}, billing_mode organization, tool_class chat. Prose: the backend policy starts as organization with tool class chat; personal needs setAgentBillingMode with a holder and is bound by I1 (link 10).
    • Step 2: file keygen.py; Shell: running it with --adapter-dir {adapter_dir}; Output dpop_jkt: {dpop_jkt}; Owner: createToken on /api/v1/principals/{agent_id}/tokens with name, dpop_jkt {dpop_jkt} and expires_at {expires_at}; Response 201 with secret {secret:agent.token} and token holding dpop_jkt {dpop_jkt}. Prose: shown once, stored only in that file, DPoP binding required while agent is in auth.dpop_required_for (08 12); uv run installs the locked SDK on first use, step 4 explains the installation.
    • Step 3: Owner: createGrant for the agent on the channel with level write and capabilities upload; Owner: getChannel with Response 200 capturing channel.version as {channel_version}; Owner: setChannelOperator with If-Match: "{channel_version}", mode agent, agent_id {agent_id} and a reason, Response 200 with operator.agent_id {agent_id}; Owner: getChannelBehavior capturing settings_version and history_backfill_limit; Owner: putChannelBehavior with If-Match: "{settings_version}" and the full body (thread_require_mention false, history_backfill_limit "{history_backfill_limit}", a short English prompt, skills empty, the other switches at their standard of 09 3.3), Response 200 with thread_require_mention false. Prose: the operator has write without a grant (09 2.3); the grant adds upload for files; If-Match per 06 8.
    • Step 4: Shell: uv sync --project sdk/python --locked; files agent.py and connect.py; one sentence on TypeScript (note 2); Adapter: start with uv run --project sdk/python --locked python sdk/python/examples/guide/agent.py --server-url {server_url} --instance-id {instance_id} --agent-id {agent_id} --adapter-dir {adapter_dir} --consumer default; Output with the line that the adapter is connected as lotse with consumer default. Prose: the developer takes the instance ID and the agent ID once from the owner and never reads them from the server at start, because the SDK checks iss and aud of every invocation token against them (09 4.5 point 4).
    • Step 5: file heartbeat.py; Output with the heartbeat line (state online, mode organization, tool class chat); prose: the SDK sends the periodic heartbeats over the gateway, the example sends the first report once with sendHeartbeat and prints the server's answer (note 8); Owner: getAgent with Response 200 holding presence.state online and presence.reported_tool_class chat.
    • Step 6: file commands.py; Output naming both commands; Alex: listChannelCommands with Response 200 capturing {summarize_command_id} (name summarize, control false) and {version_command_id} (name version, control true) from items; Alex: autocompleteCommand for summarize with option topic and a partial value; Response 200 with one choice of the example's fixed list; Output with the line that suggestions were sent.
    • Step 7: file invocation.py; Sam: createThread (a forum post with title and first_message, capturing {thread_id}; Sam has no trigger, so nothing runs); Alex: createMessage in {thread_id} asking Lotse for help; Output with the acceptance line capturing {run_id} and naming the checked token and the actual mode, then the context line listing the trust marks in thread order (Sam's message unverified, Alex's trigger).
    • Step 8: files runtime.py and run.py; Output with the lines started {run_id}, live box updated, answer sent, finished {run_id} with completed and usage reported; Alex: getRun with Response 200 holding state completed, billing_mode_used organization and the example's summary; then the model command: Alex: invokeCommand for {summarize_command_id} with thread_id {thread_id} and option topic, Response 202 with accepted true and run_id {command_run_id}; Output with that run's lines; Alex: getRun holding trigger_kind command and state completed.
    • Step 9: file interactive.py; four parts, each starting with an Alex: createThread post whose text triggers one rule of note 9 and captures its own thread id. Follow-up: the run waits for a follow-up; Alex: createMessage in that thread; Output with follow-up absorbed and run completed. Question: the run posts a question card and waits (awaitRunInput); Alex answers with a message (09 7: a follow-up to a waiting run is the answer); Output with the answer received and the run resumed and completed. Approval: the run posts the approval card of 09 11.5 and prints the component id of "Allow once" as {once_component_id}; Alex: createInteraction on /api/v1/components/{once_component_id}/interactions with client_interaction_id {new_uuid}, Response 201; Output with the decision once, resumed, completed. Stop: the run keeps running until stopped; Alex: invokeCommand for {version_command_id} in that thread, Response 202, Output with the answered control command while the run continues (09 10.4); Alex: cancelRun with Response 202; Output with the run stopped (user_cancelled); Alex: getRun with state cancelled and state_reason user_cancelled. Files: note 11.
    • Step 10: file tests/test_agent.py; Shell: uv run --project sdk/python --locked pytest sdk/python/examples/guide/tests (with --no-cov only if the project's pytest configuration measures coverage on every run, because a partial run cannot reach the threshold); prose on logs without content and secrets (proved by the runner's final checks, note 10); restart: Adapter: stop with its Output; Adapter: start with the command line of step 4 plus --stop-after accept; Output with the start lines of steps 4 to 6; Alex: createThread capturing {restart_thread_id}; Output with the acceptance line capturing {restart_run_id} and the line that the adapter stopped after the acceptance (the process then exits 0 by itself); Adapter: start with the command line of step 4 without the flag; Output with the start lines, the context line, started and finished {restart_run_id}; Alex: listThreadRuns for {restart_thread_id} holding {restart_run_id} with state completed; versions: the pin block and one sentence on the compatibility matrix of sdk/README.md; finally Adapter: stop with its Output, so the page ends with no running process.
  8. Example program (sdk/python/examples/guide/, run as scripts from the repository root, sibling modules imported by name). Ruling for docs/DECISIONS.md: one program in small step modules, each shown whole in its step.

    • keygen.py: --adapter-dir; creates the adapter's DPoP key with the SDK's auth module, writes it as adapter.key with mode 0600 (refuses an existing file), prints exactly dpop_jkt: <RFC 7638 thumbprint>.
    • agent.py: flags --server-url, --instance-id, --agent-id, --adapter-dir, --consumer (default default), --stop-after (only value accept); --instance-id and --agent-id are required and are passed to AdapterConfig, so the SDK compares them as M2-29 Design note 8 says (Discovery.instance_id at start, iss and aud of every invocation token) and checks ready.principal.id against the agent ID (M2-29 Design note 6); refuses to start when agent.token or adapter.key is readable by group or others; keeps the SDK's inbox, outbox and jti store in {adapter_dir}/state; wires the step modules; on SIGINT ends cleanly and prints the stop line. Output order fixed per process: connect (4), heartbeat (5), commands (6), then events (inbox first). The SDK sends its first gateway heartbeat after a random delay and reconciles the commands right after ready (M2-29 Design notes 6 and 12), and the server sends heartbeat_ack before it applies the member agent and reports an error of that part later as an error frame (M2-20 Design note 10), so no gateway heartbeat confirms the report. After ready the example therefore sends one heartbeat itself with sendHeartbeat through the SDK's Client.call (body from heartbeat.py) and prints the step 5 line on the 200, from HeartbeatAck.presence. It prints the step 6 line only after that line and after the SDK reports the command reconciliation done. Its prepare() waits until both lines are printed, so inbox entries start only after them. The SDK's periodic gateway heartbeats and a reconciliation after a reconnect print nothing.
    • connect.py (gateway with consumer, reconnect and ack by the SDK), heartbeat.py (state online, reported_billing_mode organization, billing_modes_supported [organization], billing_evidence with key_type none and no value, reported_tool_class chat, capabilities with followups steer and components true, and files_out only in the file branch of note 11), commands.py (declarative list: summarize as model command with option topic of type string and autocomplete true; version as control command; registered for every channel from listServedChannels; autocomplete answers from a fixed list without any model call, 09 10.5 point 5), invocation.py (token check by the SDK, actual mode and tool class compared with the claims, billing_mode_mismatch or tool_class_mismatch reported through finishRun without starting, context fetched and rendered with the SDK's function for marked data blocks, ADR 0054), run.py (start with the newest token, live-box patches through the SDK's runs helpers with a status line, one step, a text excerpt and metrics on paths allowed by 09 5.3, the answer through the outbox with run_id, finishRun with summary and costs holding one CostReport with provider and model naming the example runtime, currency EUR, token counts and no amount_micros, so the server books it as M2-22 decides for an unknown price), interactive.py (follow-up states delivered then absorbed, stop on run.cancel_requested with finishRun cancelled and user_cancelled, question card and approval card with run_id and awaitRunInput, resume with the token of the follow-up or interaction, respondToInteraction and updateComponent showing the decision, the control command answered as a message of the agent).
    • Output: one line per confirmed action on stdout, flushed, format Step <n>: <text> where <n> is the step of the guide that explains the action (connect 4, the example's own heartbeat 5, commands and suggestions 6, acceptance and context 7, start, live box, answer and end 8, follow-up, question, approval, stop and control command 9, the stop on SIGINT and the stop after acceptance 10), printed only after the server confirmed the action (2xx for a REST call; for a gateway op such as command.autocomplete_result, after the SDK reports it sent); never message content, prompt, token, key, display name or principal ID of a person; run, thread and component IDs are allowed. SDK logs go to stderr under the same rule.
    • Only public names of the merged SDK. If the hand layer lacks a public function a step needs (expected candidates: creating and storing a DPoP key with its thumbprint; a hook between acceptance and start for --stop-after accept; a signal that the command reconciliation after ready is done), add the smallest public function to the hand-layer module of 11 11.2 it belongs to, with a docstring, a test and a CHANGELOG line, and list it in the report; never write cryptography or protocol code in the example.
  9. Example runtime (runtime.py). Ruling for docs/DECISIONS.md: fixed rules on the trigger text replace a model, so the guide costs nothing and its output is deterministic. Case-insensitive, first match wins: contains follow-up: wait for a follow-up, then answer; contains question: ask with a card of two buttons and wait; contains approval: ask with the approval card and wait; contains long: keep running until stopped; contains file (only in the file branch): answer with a text file; a model command summarize: answer with a short summary naming the option value; anything else: a short fixed answer. The runtime never reads or echoes the prompt.

  10. Runner (services/server/test/guide, package guide, component testsupport; Go, because the owner and the people need the device-key and DPoP clients the Go test support offers).

    • Fresh server: reuse the harness the merged M2-31 suite uses for the real server (expected: services/server/test/harness of M1-71 with Start, the owner client and people with device keys and DPoP; otherwise the helpers of services/server/test/agents on identitytest). New PostgreSQL database through pgtest, migrations as bothy migrate up --bootstrap runs them, the real internal/wire graph with role all, listeners on 127.0.0.1:0, first owner through the setup path of M1-37 (in the closure). Configuration: only ratelimit.* raised (R-QA-11); no other key differs from the standard. Never build a second harness; add a missing helper there additively.
    • People: Alex and Sam created through the harness before the first block; their IDs bind {alex_id} and {sam_id}.
    • Repository root: the directory above the package that holds mkdocs.yml; the page is read from docs/guides/connect-agent.md, the step titles from 11 13 of docs/spec/11-extensions.md, the operations from docs/api/openapi.yaml, the version from sdk/python/VERSION.
    • Processes: environment of the test without every variable that starts with BOTHY_; working directory the repository root; stdout and stderr read line by line into buffers; each adapter in its own process group; at the end of the test, or on failure, any process still running gets SIGKILL on its own group.
    • Final checks after the last block: every step section had at least one executed block; every adapter process exited 0 with all stdout lines matched; no {secret:...} value, no string matching a compact JWS (three base64url parts joined by dots, the first starting with eyJ) and no content_md value of any request block of the page appears in any stdout or stderr of the adapters or the Shell: blocks; the run {restart_run_id} has exactly one event run.state_changed with state running (read through the harness's administrative database access, assertions only) and its thread has exactly one run.
    • Logging: the runner logs block by block (t.Logf) with step and title, never a secret, never a request body with a secret.
  11. Files (09 4.7 row "Send file", 11 13 step 9). If MessageCreate.attachment_file_ids is enabled on the base branch (M3-07 merged), step 9 gets a fifth part: Alex posts a thread with the word file, the example uploads a small text file it creates in memory through createUpload, uploadPart and completeUpload (from the generated core; the hand layer has no file module) and answers with attachment_file_ids, the heartbeat reports files_out true, the output line captures {file_message_id}, and Alex: getMessage holds attachments with file.original_name of that file; use the file setup the merged M3-07 tests use for the test server. Otherwise step 9 says in prose that this server version accepts no attachments yet and proves it: Alex: createMessage with attachment_file_ids ["{new_uuid}"], Response 400 holding code validation_failed and errors with pointer /attachment_file_ids and code not_enabled. Ruling for docs/DECISIONS.md: the text states only what the server does, and the check fails as soon as attachments are enabled, so the ticket enabling them must extend the step (plan gap reported).

  12. Costs and SR9. Step 8 finishes with costs (11 13 "run.finish with usage"); M2-22 books them. Step 8 mentions the binding of a run to the rights of its trigger (SR9, the SDK sends the token along); M2-14, which builds it, is in the closure through M2-29.

  13. Lint, types, coverage. The examples are hand-written code of the component sdk-python: SPDX header # SPDX-License-Identifier: Apache-2.0 as first line, ruff with D (convention google, tests excluded) and the formatter, mypy with the project's strict configuration (add examples/guide to its paths), bandit. sdk/python/pyproject.toml: pytest collects examples/guide/tests and puts examples/guide on its pythonpath; coverage measures examples/guide next to the SDK package, so sdk/python/coverage.xml covers the code Sonar sees under sdk/**; the threshold quality.coverage.python_min_percent (added to 23 11 by M2-23) applies. The test file sits under tests/, which sonar.test.inclusions already marks as test code. Ruling for docs/DECISIONS.md: examples follow every rule of hand-written code.

  14. CI path selection (23 4). Ruling for docs/DECISIONS.md: in GATE_COMPONENTS of tools/ci/components.py the prefixes docs/guides/connect-agent.md and docs/spec/11-extensions.md select server and sdk-python, and sdk/python/examples/ additionally selects server, because the check run is a server test that reads these files; otherwise a change of the text or the example would skip the test that holds them.

  15. Specification and documentation text (English like each file). 11 13: one sentence after the introduction that the guide is docs/guides/connect-agent.md and runs as a test against a fresh server that fails when a step differs from the text; step 1 "The owner creates the agent (administration in the app or console or operation createAgent; ...)"; step 2 "bound to an adapter key (DPoP), required while agent is in auth.dpop_required_for (default, 08 section 12)" instead of "recommended". Keep the bold step titles. 09 2.1 row "Access": dpop_jkt required while agent is in auth.dpop_required_for (default) instead of "recommended". 02 6: guide/ in the list of test/. docs/quality/gates.md row test: the check run of the guide runs in the server's tests and needs uv. docs/api/README.md, sdk/README.md, sdk/python/README.md: one link each to the guide. The ## How the repository checks this guide section and services/server/test/guide/README.md describe the grammar of note 3 for maintainers.

  16. Rulings for docs/DECISIONS.md, one row each D-M2-37-<n> with date, decision, reason and place: page, language and nav (note 2); Python as the only executable path (note 2); owner steps through the API (note 2); the block grammar (note 3); placeholders and secret captures (note 4); the matching rules (note 5); actors and a fresh step-up before every owner request (note 6); the example's layout and output format (note 8); the rule-based runtime (note 9); the runner in Go with the shared harness (note 10); the files branch and its check (note 11); the scope of lint, types and coverage for examples (note 13); the CI path selection (note 14).

Tests (write them first)

Go unit tests (services/server/test/guide, no server; small synthetic pages in the test code plus mutated copies of the real page):

  • TestParse_StepHeadingsFollowChapter11: ten headings ## Step 1: to ## Step 10: with the bold titles of 11 13 pass; a renamed title, a missing step, a duplicated step, two swapped steps and an eleventh step each fail with the step number.
  • TestParse_BlockKinds: one block of every kind of note 3 is recognised with its fields; an untitled fence, a fence with an unknown title form, an indented titled fence, a fence in ## Next, an Agent: request, an Adapter: stop block with another body and a second Adapter: start while the first adapter may still run each fail with line number and reason.
  • TestValidate_FileBlocksEqualExamples: equal content passes; one changed byte fails naming the file and the first differing line; a file of sdk/python/examples/guide/ not shown, a file shown twice and a title naming a missing file each fail.
  • TestValidate_RequestLinesMatchOpenAPI: POST /api/v1/agents HTTP/1.1 for createAgent passes; an unknown operationId, another method, another path and a missing HTTP/1.1 each fail.
  • TestValidate_Placeholders: use before capture, a second capture of the same name, an unknown placeholder, a {secret:...} outside a response block, and a placeholder table that lacks or adds a runner-bound name each fail.
  • TestValidate_PinMatchesSDKVersion: the pin equal to sdk/python/VERSION passes; another version, a second SDK entry and invalid TOML fail.
  • TestMatchJSON: equal scalars; missing member (pointer in the error); same value with another JSON type; nested objects; arrays with one unique match, no match and an ambiguous match; captures keep their JSON type; a bound placeholder compares.
  • TestMatchLines: exact lines; capture of \S+; a differing line fails at once with expected and actual line; a process that ends early fails; extra unexpected lines fail at the end.
  • TestSecretsNeverInErrors: a mismatch in a response block that captured {secret:agent.token} produces an error and a log without the secret value; the secret file has mode 0600.
  • TestMutations_Static: the real page with one mutation each (a changed line in a shown file, steps 5 and 6 swapped, step 9 removed, an unknown operationId, a wrong path, an unbound placeholder, an untitled block, a changed pin) fails before any server starts, naming the step.

Go integration tests (PostgreSQL 18 via pgtest, the fresh server of note 10, uv and the locked sdk/python environment):

  • TestGuide (exit criterion 7): runs the whole page and the final checks of note 10; the log lists each step with its executed blocks.
  • TestGuide_DeviationFailsAtStep1: the page with "billing_mode": "organization" in step 1's response block changed to personal fails at Step 1, block Response 201, pointer /billing_mode; no block after it runs and no adapter process was started.

Python tests (sdk/python/examples/guide/tests/test_agent.py, pytest, the SDK's FakeServer on 127.0.0.1 port 0, shown in step 10):

  • test_ag08_invalid_token_starts_nothing (AG-08, adapter side): a missing token, a wrong signature, alg other than EdDSA, a foreign aud and another typ each start no runtime and end the run with finishRun failed and invalid_invocation_token.
  • test_ag09_double_delivery_starts_once (AG-09, adapter side): the same invocation delivered twice with different tokens gives exactly one startRun, with the newer token (09 4.3 "Duplicate detection").
  • test_ag11_billing_mode_mismatch_starts_no_runtime (AG-11): a token with billing_mode personal starts no runtime and ends the run failed with billing_mode_mismatch.
  • test_ag12_patch_conflict_is_rebased (AG-12): a 409 base_version_conflict from the FakeServer makes the example rebase on the returned document and patch again.
  • test_ag28_expired_token_is_renewed (AG-28): an expired token is renewed through renewInvocationToken before the start; the run starts once.
  • test_runtime_rules: each rule of note 9 and the order of the rules.
  • test_keygen_writes_private_key_file: mode 0600, a thumbprint of 43 base64url characters, an existing file is not overwritten.
  • test_refuses_readable_secret_files: agent.token or adapter.key with group or other read rights stops the start with a message naming the file, not its content.
  • test_output_has_no_content_or_secret: a FakeServer run with a canary in every message text and in the token; stdout and stderr contain neither.
  • test_start_lines_order: against the FakeServer with a FakeClock, the start lines come in the order 4, 5, 6 for several seeds of the SDK's jitter, including one whose first gateway heartbeat comes before the command reconciliation ends; further gateway heartbeats and a reconnect with a new ready add no line.

Repository tools (tools/ci/tests/test_components.py): docs/guides/connect-agent.md and docs/spec/11-extensions.md select sdk-python and server; sdk/python/examples/guide/agent.py selects sdk-python and server; docs/spec/09-agents.md still selects nothing.

Red team focus (PLAN 8 "Red team": forging or reusing a token, faking the actual mode) is covered by test_ag08_invalid_token_starts_nothing, test_ag11_billing_mode_mismatch_starts_no_runtime, test_output_has_no_content_or_secret and the final checks of TestGuide; name them in the red team request, together with the question whether the guide teaches any insecure pattern (token in a command line, a world-readable key, logging content).

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./test/guide/...
  • cd sdk/python && uv run --locked pytest examples/guide/tests (with --no-cov under the condition of Design note 7, step 10)
  • cd tools && uv run --locked --quiet pytest ci/tests/test_components.py
  • make lint, make typecheck, make coverage, make licenses, make docs, make linkcheck
  • make check

Acceptance criteria

  • docs/guides/connect-agent.md is in the site's nav under Guides, has the ten steps of 11 13 with their titles in order, and every block on it is executed or compared.
  • TestGuide runs every step against a fresh server and is green (exit criterion 7); every adapter process ends with exit code 0 and every output line is matched.
  • A deviation between text and reality fails the check at that step: proven by TestGuide_DeviationFailsAtStep1 against the real server and by TestMutations_Static, TestMatchJSON and TestMatchLines for files, order, titles, placeholders, requests, answers and output lines.
  • The example adapter in sdk/python/examples/guide/ uses only public SDK names, never calls a model, binds its token to its DPoP key, starts runs only after the checks of 09 4.5 and I3, and resumes a run after a restart from its inbox exactly once.
  • No token, key, secret, prompt or message content appears in any output of the check run.
  • The example passes AG-08 (adapter side), AG-09, AG-11, AG-12 and AG-28 against the FakeServer.
  • Step 9 holds either the executable file part or the check that attachments are still not_enabled, matching the base branch (Design note 11).
  • 11 13, 09 2.1 and 02 6 are updated as in Design note 15; the CI path selection runs the check for changes of the guide, chapter 11 and the examples.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of services/server/test/guide and sdk/python/examples written; sdk/README.md, sdk/python/README.md, docs/api/README.md and docs/quality/gates.md updated; the changes to 11, 09 and 02 made in the same change.
  • Rulings of Design note 16 recorded in docs/DECISIONS.md section 10 (one row each with reason).
  • services/server/CHANGELOG.md, sdk/python/CHANGELOG.md and root CHANGELOG.md entries under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check, and the duration of TestGuide.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; include the fact of Design note 1 (attachments), the branch of Design note 11 that was built, every name that differs from this brief, and every public function added to the SDK.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.