Skip to content

M2-30: TypeScript SDK with webhook verifier

Status: ready | Depends on: M2-26, M2-29 | Milestone: M2

Goal

After this ticket sdk/typescript is a complete SDK: on the generated core of M2-23 it has the same hand-layer modules as the Python SDK of M2-29 (auth, gateway, invocation, runs, components, commands, outbox, retry, heartbeat, webhooks, testing, 11 11.2), it runs the same shared scenarios as M2-29 (AG-08 adapter side, AG-09, AG-12, AG-28 and AZ-P6 for the SDK) against its own in-process FakeServer and against the real server in make check, and it verifies Standard Webhooks signatures for apps: v1a (Ed25519 with the keys of getWebhookKeys) and v1 (HMAC-SHA256 with a whsec_ secret) over the raw body, with the timestamp window webhook.receiver_tolerance, proven against every case of the test vectors that M2-26 committed. Every export carries a TSDoc comment, typedoc reports an undocumented export as an error and fails the script lint (gate of 23 "Documentation in code"), the typedoc reference is built in make docs and linked from the documentation site, and npm run build produces the ESM build output dist/ that the GitHub reference app (M2-49) and later integrations use. Why: integrations in the webhook world are usually written in TypeScript (ADR 0026, option 4), R-EX-11 requires one real app per SDK, and a receiver that verifies by hand gets the raw-body rule, the time window or the constant-time comparison wrong (webhooks.md 3, RK-20); the security-critical adapter parts (token check, inbox, start only after the promise) must be built once per language and proven by the same scenarios (ADR 0026, RK-09).

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Test doubles": FakeServer M2-29, and "Contract"); section 8 "M2 Agent platform, extensions and terminal": "Goal" (P-18), "Scope" ("SDKs for Python and TypeScript"), "Exit criteria" items 4 and 10, "Test strategy" ("FakeServer in the SDK", "contracts G4, G5, G10"), "Red team" ("forging or reusing the invocation token", "faking the reported mode", "confused deputy through the server API"), "Risks"; block "agents and extensions" rows M2-23, M2-26, M2-29, M2-30 (this ticket), M2-31, M2-37 and block "reference integrations" row M2-49 (the GitHub app is built on this SDK); section 12 row M6-14 (adds stats fields to both hand layers later); section 19 P-09, P-10, P-11, P-16, P-18.
  • Dependencies: the header names M2-26 and M2-29. Implied through them and therefore merged before this brief starts: M2-23 (generated core, tolerance layer, generate-check), M2-14 (header Invocation-Token, components.parameters.InvocationToken; a dependency of M2-29 since D-W-106), M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M2-24, M2-01 and the M1 tickets in their closure, among them M1-71 (shared test harness services/server/test/harness), M1-44 (API tokens), M1-33 (DPoP), M1-32 (JWKS, signing keys).
  • Briefs and merged code of the dependencies (merged names win, D-W-100): the M2-29 brief in docs/briefs/ (file name in the index) and everything it merged under sdk/python/, sdk/testdata/ and services/server/test/ (the scenario functions and doubles of bothy_sdk.testing, render_context with its tests, the live driver that runs the Python SDK against the real server, that is services/server/test/sdkserver and sdk/python/tests/realserver/conftest.py, its stdin protocol, the docs reference page and its nav entry); docs/briefs/M2-23-generated-clients.md (Design notes 1, 6 to 8, 10 to 13: layout, createApiClient, toleranceMiddleware, decodeEvent, knownEvents, tool chain, coverage key quality.coverage.typescript_min_percent); docs/briefs/M2-26-webhooks-outgoing.md (Design notes 11 and 12: signing set, header order, whsec_ and whpk_ encodings, the vector file docs/api/webhook-test-vectors.json); docs/briefs/M2-06-invocation-token.md (claims, header, reference verifier: the SDK verifiers must agree with it), M2-07-agent-consumers.md (consumer, ack, 4015, resume of agent sessions), M2-08-invocation-delivery.md (a fresh token per delivery), M2-09-run-lifecycle.md (answers of startRun, resumeRun, renewInvocationToken, finishRun), M2-10-run-document-live-box.md (patchRun, allowed paths, 409 base_version_conflict with current), M2-14-acting-for.md (header Invocation-Token, Design note 1), M1-71-contract-tests.md (section "Shared test harness (the same interface in the briefs M1-69 to M1-72)").
  • docs/spec/11-extensions.md: 1 "Principles"; 2 "Agent or app"; 7 "Outgoing webhooks"; 9 "Commands and interactions for apps" (interactions_url is "signed like outgoing webhooks"); 10.1; 11 "SDK design" (11.1, 11.2 every row, 11.3 "Runtimes and dependencies": "The final choice is made by the build ticket with the license gate"); 12 "Versioning and compatibility" (deprecation warning once per operation); 14 R-EX-10, R-EX-11; 16 rows webhook.receiver_tolerance, sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap.
  • docs/spec/09-agents.md: 2.4 "Tool class"; 4.1 to 4.10 (4.3 inbox and acknowledgement, 4.5 "Invocation token" with points 1 to 10 and the reason table, 4.5.1, 4.6 "Run context" with trust, 4.7, 4.8 "Heartbeat and reported state", 4.9 "Order in the adapter: commitment first, then runtime"); 5.3 "Run document (run_document.v1)" (path table); 7 "Follow-ups during a run"; 8 "Stop"; 10.2 "Registration", 10.5 "Autocomplete"; 11.1, 11.5, 11.6; 16.1 "Test doubles" (row FakeServer); 16.2 rows AG-08, AG-09, AG-12, AG-28; 17.6 (format of the marked context in Hermes); 19 R-AG-07, R-AG-17, R-AG-24, R-AG-25; 21 rows adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, component.*, command.*.
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.2 (delivery fields invocation_token, redelivered, sync_epoch; rule 3: no tokens in payloads); 5 "Idempotent writes"; 6.1 to 6.5 (ops, lifecycle, close codes, reconnect); 7.4; 15.1 to 15.5, 15.7.
  • docs/spec/06-api.md: 2 "Basic rules" (rows "No null", "Unknown values", "Client identifier"); 3 and 3.1 "DPoP"; 5 "Errors"; 6 "Pagination"; 7 "Idempotency" (the generated list of x-idempotency-exempt); 13 point 4 (deprecation headers); 14 point 2 (what each hand layer does); 15 rows G4, G5, G10; 19 R-API-04, R-API-14, R-API-24.
  • docs/spec/07-permissions.md: "SR9 Acting-for binding (confused deputy)"; 11.2 row AZ-P6. docs/spec/08-identity.md: 7.3 "DPoP". docs/spec/05-actions.md (core contract, read only): 5 "Agent invocation agent.invoke" (claims).
  • docs/spec/23-quality.md: 1 points 3 to 5; 3 "Component contract" with "Documentation in code" (row TypeScript: "typedoc with the check for undocumented exports as an error", "typedoc reference in the docs site"); 10 R-QA-02, R-QA-14. docs/spec/19-security.md: SEC-AI-1, SEC-LOG-2, SEC-SC-1.
  • docs/api/webhooks.md: 2 "Delivery", 2.1 "Signing keys", 3 "Verification by the receiver", 9, sources W1 and W2; and the section on the test vectors M2-26 added.
  • docs/api/openapi.yaml: operations getDiscovery, getJwks, getWebhookKeys, pollAgentEvents, ackAgentEvents, getConsumerState, getAgentTriggerers, startRun, resumeRun, renewInvocationToken, awaitRunInput, finishRun, patchRun, getRunContext, createMessage, sendHeartbeat, registerCommand, updateCommand, removeCommand, getCommand, listChannelCommands, answerAutocomplete, updateComponent, respondToInteraction, reportAgentSession, listEvents; schemas Problem (with x-problem-codes, reason, errors, current), FieldError, WebhookKeys, WebhookKey, EventEnvelope, Event, EventPage, AgentInvocationEvent, AgentInvocationPayload, AgentFollowupEvent, InteractionCreatedEvent, CommandInvokedEvent, RunCancelRequestedEvent, CommandAutocompleteRequestedEvent, RunStart, InvocationTokenRenew, InvocationTokenGrant, RunAwaitInput, RunFinish, RunPatch, RunPatchResult, JsonPatchOperation, RunContext, RunContextMessage, RunContextAttachment, Heartbeat, HeartbeatAck, ConsumerAck, Command, CommandRegister, AgentTriggerers, AutocompleteAnswer, MessageCreate, Jwks, GatewayHello, GatewayIdentify, GatewayReady, GatewayResume, GatewayResumed, GatewayHeartbeat, GatewayHeartbeatAck, GatewayAck, GatewayRunUpdate, GatewayAutocompleteResult, GatewayCursor, GatewayCatchupComplete, GatewayResyncRequired, GatewayError; components.parameters.InvocationToken (M2-14); the webhooks: section. docs/api/asyncapi.yaml: channel gateway (messages and x-close-codes, info.x-protocol-versions).
  • ADRs: 0013, 0014, 0016 (all, "Confirmation"), 0023, 0026 (all, "Confirmation": the same scenarios for both SDKs; no own hand types for core objects), 0045 (documentation site), 0052, 0053, 0054, 0061.
  • docs/DECISIONS.md: D-R-CLIENTS, D-API-13, D-QA-22, D-QA-25, D-W-33, D-W-54, D-W-100, the rows D-M2-23-<n>, D-M2-26-10 to D-M2-26-12 and D-M2-29-<n>; section 10 "Rulings of the build milestones" (row format D-M2-30-<n>).
  • docs/RISKS.md: RK-09, RK-13, RK-15, RK-20, RK-21. docs/OPEN-QUESTIONS.md: OQ-17 (no package name reserved, nothing published). docs/security/threat-model.md: T-TK-05, T-PL-01, T-SC-01, T-AI-01, T-AI-03.
  • docs/operations/configuration.md: rows sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, webhook.receiver_tolerance, webhook.retry_max_age, agent.jwks_max_age, agent.run_update_min_interval, agent.ack_timeout, agent.invocation_token_ttl, auth.clock_leeway, sync.reconnect_backoff_base, sync.reconnect_backoff_cap, sync.resume_window, component.max_per_message, component.custom_id_max_chars, component.form_max_fields, command.description_max_chars, command.autocomplete_max_choices, command.max_per_principal.
  • docs/quality/gates.md (rows lint, docs, linkcheck, api-clients, coverage, licenses), docs/quality/toolchain.md, docs/quality/licenses.md, mkdocs.yml, tools/docs/mkdocs_repo_links.py (pattern of an MkDocs hook), tools/docs/tests/test_toolchain_doc.py, .github/workflows/docs.yml, .github/workflows/ci.yml (job gates: actions/setup-node, make bootstrap-ci), sonar-project.properties, quality/licenses/allowed.txt, sdk/README.md, sdk/typescript/README.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.

Note on rule 3: for this component "API documentation is generated from code" is the typedoc reference built from the TSDoc comments of sdk/typescript/src in make docs (23 "Documentation in code", D-W-33).

Product requirements implemented here:

  1. The hand layer has the modules of 11 11.2 (auth, gateway, invocation, runs, components, commands, outbox, retry, heartbeat, webhooks, testing) with the behaviour of the Python modules of M2-29; it sits on the generated core, which is never changed by hand (11 11.1, R-EX-10), and declares no own type for an object the core generates (ADR 0026 "Confirmation").
  2. The same scenarios as M2-29 run against the TypeScript FakeServer and against the real server: AG-08 (adapter side), AG-09, AG-12 and AG-28 (acknowledgement after acceptance into the inbox, renewal of the token, a newer token of a redelivery replaces the stored one) (PLAN rows M2-29 and M2-30; 09 16.2; ADR 0026 "Confirmation").
  3. AZ-P6 for the SDK: an adapter built on the SDK never starts the runtime with a missing token, a wrong signature, a foreign aud, an expired token, a repeated jti, a different billing_mode or a higher tool class (07 11.2; 09 4.5; T-TK-05).
  4. One hand-layer function assembles the run context with trust marks as marked data blocks: unverified content only as marked data behind the fixed notice, file contents always as data, a transcript with the mark of its message (09 4.6, ADR 0054, SEC-AI-1), byte for byte like the Python function on shared golden files rendered with a fixed test boundary; outside tests the block boundary is random per call (M2-29 Design note 11).
  5. Webhook verification after Standard Webhooks for apps, also for requests to interactions_url: signed content {webhook-id}.{webhook-timestamp}.{body} over the raw body bytes; webhook-timestamp within webhook.receiver_tolerance of the own clock; every entry of webhook-signature tried against the trusted keys and secrets, one valid entry suffices; v1a keys come from getWebhookKeys (whpk_) as a trust list and never from the request; v1 secrets are whsec_ values compared in constant time (webhooks.md 2, 2.1, 3; 11 7, 9, 11.2; ADR 0016; R-API-14); every case of docs/api/webhook-test-vectors.json verifies (PLAN row M2-30, D-M2-26-12).
  6. The invocation token is checked in the adapter by points 1 to 10 of 09 4.5 with a JWKS cache that reloads once for an unknown kid and at most once per adapter.jwks_refresh_min_interval, the allow list EdDSA, typ = agent-invocation+jwt, iss, aud, time with adapter.invocation_token_leeway, a jti store and a callback for the actual mode (11 11.2 row invocation, 11 11.3).
  7. An event with a token is acknowledged only after it passed the check (expiry excepted) and was stored with its token in the inbox; the start follows from the inbox; 403 invocation_token_invalid is handled per reason (expired: renew, start once more; superseded: wait for the newer token; invalid: finish failed with invalid_invocation_token); 409 already_started starts nothing; the runtime starts only after 200 (09 4.3, 4.5, 4.9; 04 15.2, 15.3; R-AG-07, R-AG-24, R-AG-25).
  8. The gateway client follows 04: hello, identify with api_token, consumer and a DPoP proof when the token is bound, resume, heartbeat with random offset, ack window, reconnect with full jitter between 0 and min(sync.reconnect_backoff_cap, sync.reconnect_backoff_base * 2^n), the reaction per close code of 04 6.4 (4015 ends without reconnecting); unknown ops, fields, event types and close codes are tolerated and a received JSON null counts as absent (04 6.1 to 6.5, 15.1, 15.2; 06 2; ADR 0053).
  9. Retries only for idempotent operations: network errors, timeouts, 5xx and 429 with Retry-After; full jitter between sdk.retry_backoff_base and sdk.retry_backoff_cap; at most sdk.retry_max_attempts attempts; each request limited by sdk.http_timeout (11 11.2 row retry, 11 16).
  10. The outbox stores client_msg_id before sending (replaceable store) and uses Idempotency-Key for other writes (11 11.2; 04 5; 06 7).
  11. Errors are typed after the problem format; a 404 means "not there or not visible" and is never treated as "definitely missing"; unknown problem codes are handled by status (11 11.2; 06 5).
  12. DPoP proofs with a key from a file or a key store, DPoP-Nonce kept and one retry on 401 use_dpop_nonce (06 3.1; 08 7.3); the gateway proof uses the nonce of hello (04 6.3 point 3).
  13. One warning per operation when the server sends Deprecation or Sunset (11 12; 06 13 point 4).
  14. The heartbeat building block for agent_heartbeat.v1 rejects fields that could carry secrets (11 11.2 row heartbeat; 09 4.8).
  15. Components within the limits of 09 11.1 (approval card of 11.5, question of 11.6); commands as a declarative list reconciled on connect; autocomplete with a guard that never answers after the request expired (09 10.2, 10.5; 11 11.2).
  16. ESM for the active LTS lines of Node.js; in a browser only the parts without secrets (11 11.3).
  17. Documentation in code: typedoc reports an undocumented export as an error and fails the script lint (test with a deliberately undocumented export); the typedoc reference is produced in the build of the documentation site and linked there (23 "Documentation in code"; R-QA-14; D-W-33; PLAN row M2-30).
  18. Dependencies locked with checksums; license and vulnerability gates green without a change to the allowlist (SEC-SC-1, T-SC-01).
  19. Tokens, DPoP proofs, secrets and message content never appear in logs, error messages or test output (SEC-LOG-2; 04 2.2 rule 3).
  20. Nothing is published to npm and no name is reserved (OQ-17).
  21. During a run every REST request the SDK sends for that run carries the run's newest invocation token in the header named by components.parameters.InvocationToken (Invocation-Token), automatically; outside a run no request carries it (07 SR9; M2-29 requirement 19 and Design note 5 bind_run).

Scope

Paths are relative to the repository root. Where M2-23 or M2-29 merged a compatible name or file, keep it and list the difference in the report.

Create:

  • sdk/typescript/src/ hand layer, one folder per module with an index.ts that re-exports its public API: auth/ (DPoP keys and proofs, Design note 4), retry/ (classification table and backoff, note 4), gateway/ (GatewayConsumer, PollingConsumer, frame router, note 5), invocation/ (JwksCache, InvocationVerifier, JtiStore, Inbox with MemoryInboxStore and FileInboxStore, note 6), runs/ (AgentLoop, RunHandle, RunUpdater, renderContext, notes 7 and 8), components/, commands/, outbox/, heartbeat/ (note 9), webhooks/ (verifyWebhook, verifyWebhookRequest, WebhookKeyCache, key and secret parsers, note 10), testing/ (FakeServer, signWebhook, the scenario functions and doubles of note 2 in scenarios.ts, note 11); plus src/errors.ts (ProblemError, TransportError, TimeoutError, KNOWN_PROBLEM_CODES), src/config.ts (defaults named by their keys, note 15), src/ids.ts (uuidv7()), src/pages.ts (cursor iterator), src/core.ts (browser-safe entry, note 3).
  • sdk/typescript/tsconfig.build.json (emit ESM and declarations to dist/), sdk/typescript/typedoc.json (note 13).
  • Tests of the section "Tests" under sdk/typescript/test/ and sdk/typescript/test/live/, with test/live/global-setup.ts and test/live/sdkserver.ts (note 12).
  • tools/docs/mkdocs_sdk_reference.py (MkDocs hook, note 14) with tools/docs/tests/test_mkdocs_sdk_reference.py.
  • tools/ci/tests/test_sdk_typescript_doc_gate.py (note 13).
  • The English page docs/api/sdk-typescript.md next to the Python page of M2-29 (note 14).
  • sdk/testdata/scenarios/README.md (English: the steps and expected outcomes of each scenario) and sdk/testdata/context/ (input JSON, expected system and trigger texts, English README.md) (note 2).
  • sdk/python/tests/test_context_golden.py and sdk/python/tests/test_scenario_list.py (note 2).

Change:

  • sdk/typescript/package.json and package-lock.json: dependencies jose, ws (note 3), dev dependencies typedoc, @types/ws; scripts build, docs, docs:check, lint (eslint then npm run docs:check); exports, files, sideEffects (note 3). sdk/typescript/src/index.ts, the module of createApiClient (note 4), vitest.config.ts (globalSetup test/live/global-setup.ts, coverage excludes test/live/, note 12), eslint.config.mjs and .prettierignore (ignore dist/ and build/).
  • .gitignore: sdk/typescript/dist/ and sdk/typescript/build/. sonar-project.properties: exclude both directories if they are not excluded already.
  • Makefile (root): recipe docs builds the typedoc reference before MkDocs when sdk/typescript/package.json is tracked; recipe linkcheck excludes the mount path of the generated reference from lychee (note 14). No new target.
  • mkdocs.yml: hook tools/docs/mkdocs_sdk_reference.py and the nav entry of docs/api/sdk-typescript.md in the section where M2-29 put the Python page. .github/workflows/docs.yml: Node setup and SDK install before make docs (note 14).
  • tools/docs/tests/test_toolchain_doc.py and docs/quality/toolchain.md (row typedoc); docs/quality/gates.md (rows lint, docs, linkcheck).
  • tools/ci/components.py and tools/ci/tests/test_components.py: sdk-typescript also owns the prefix services/server/, as M2-29 did for sdk-python, so a server change runs the live files in CI (note 12).
  • services/server/test/sdkserver/README.md: one sentence that sdk/typescript/test/live/ drives it as well (note 12); nothing else under services/server/.
  • docs/spec/11-extensions.md: 11.2 (the TypeScript entry points and that webhooks verifies v1a and v1), 11.3 (TypeScript bullet: JOSE with jose, webhooks and DPoP with the Node.js standard library, ws only in the FakeServer); docs/spec/23-quality.md "Documentation in code" (TypeScript row: validation.notDocumented as error in the script lint, reference built in make docs); docs/api/webhooks.md 3 point 3 (refresh rule of the trust list, note 10) and one sentence naming the SDK verifiers.
  • docs/adr/0026-sdks-generated-core-plus-hand-written-layer.md "Confirmation": name the scenario tests of both SDKs and the type-duplication test; the decision stays; then make adr-index.
  • No change under sdk/python/ except the two new test files named under "Create" (note 2); no change under sdk/python/src.
  • sdk/README.md (table "State", modules), sdk/typescript/README.md (English: status, layout, entry points, Node line, modules, webhook verification with an example, FakeServer, live runs, how to build the reference), sdk/typescript/CHANGELOG.md and root CHANGELOG.md under "Unreleased", docs/DECISIONS.md section 10 rows D-M2-30-1 and following (note 16).

This ticket adds no operation, event, action, problem code or configuration key and creates no new top-level make target.

Out of scope

  • The Python SDK and its FakeServer (M2-29); the conformance suite with the Fake-Agent (M2-31); the guide with its test (M2-37); the reference apps (M2-49 on this SDK, M2-50 on the Python SDK); stats fields of the hand layers (M6-14).
  • Server operations used by modules of this SDK that later tickets enable: getRunContext (M2-13), agent sessions (M2-12), components and interactions (M2-15), commands (M2-16), autocomplete answers (M2-17), agent messages with run_id (M2-19), sendHeartbeat (M2-20). Until then these modules are proven against the FakeServer only; the live scenarios never call them.
  • Webhooks as agent transport (04 15.6; no ticket owns it yet); duplicate detection of webhook-id at the receiver (webhooks.md 3 point 4 is the app's duty, Design note 10).
  • Publishing to npm, the final package name, the compatibility matrix test per released SDK version (OQ-17, V1-06, M7 release chain); release-build of the component (M7); G6 (M7-11); the Dart client (M4-02).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-30-typescript-sdk; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(sdk-typescript): ..., build(docs): ..., test(server): ...) 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 to npm and never reserve a name there (OQ-17); the package stays "private": true.
  • 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 (P-10); PostgreSQL runs only as a throwaway container on a random port inside M2-29's sdkserver (P-09). Without a Docker-compatible runtime or without the Go tool chain the live files fail; they never skip.
  • No real third-party service and no real DNS name in any test; base URLs are the FakeServer's or the live server's loopback address, examples use example.com.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d or the test framework's temporary directory, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and npm packages; npm installs run with --ignore-scripts (T-SC-01). Every whsec_ value in tests carries the marker EXAMPLE (gitleaks stopword); never weaken .gitleaks.toml.
  • Dependencies pinned exactly in the lock; dev tools exact in package.json; no latest, no unpinned npx. Do not extend quality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop with Verdict: BLOCKED <package and license>.
  • Never edit the generated core by hand; regenerate with make -C sdk/typescript generate. 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-23, M2-26 and M2-29: their merged names win over the expected names here; list each difference.

Design notes

  1. Preconditions, before writing code. Stop with Verdict: BLOCKED <reason> when one of these holds on the base branch: docs/api/webhook-test-vectors.json is missing or has no signature case; M2-29 merged no sdkserver (services/server/test/sdkserver with its stdin protocol) or no run of its scenarios against it inside make check (there is no real server to run the TypeScript scenarios against, and building a second live mechanism would split the contract); components.parameters.InvocationToken is missing from docs/api/openapi.yaml (Verdict: BLOCKED dependency M2-14 missing; M2-14 is in this closure through M2-29); make -C sdk/typescript generate-check or make api-clients is red. Otherwise continue.

  2. Parity with M2-29 (Ruling for docs/DECISIONS.md): one behaviour, two languages. The shared files under sdk/testdata/ (the scenario list, context golden files, M2-23's tolerance fixtures) and docs/api/webhook-test-vectors.json are the contract between the SDKs. Module names are those of 11 11.2; function names are the camelCase form of the Python names, class names the same. Where this brief and M2-29's merged behaviour differ on a point the specification leaves open, follow M2-29 and record the difference; where either differs from the specification, stop with Verdict: BLOCKED <reason>. Port M2-29's five scenario functions step for step to sdk/typescript/src/testing/scenarios.ts (ag08InvalidTokens, ag09DoubleDelivery, ag12PatchRules, ag28SlowStartAndRestart, azp6AdapterNeverStarts) together with the doubles they use (FakeClock, TamperingSource, RecordingSource, RecordingHandler, fingerprint). They run against a TypeScript ScenarioServer with the members of M2-29's protocol, which the FakeServer and the sdkserver client of note 12 implement. For parity, sdk/testdata/scenarios/README.md (English) lists each scenario under its Python name with its steps and expected outcomes, and one test in each SDK checks that its scenario names equal that list (sdk/python/tests/test_scenario_list.py; TypeScript compares the camelCase form in scenarios.test.ts). sdk/testdata/context/ holds input JSON (a RunContext body and the file_texts) and the expected system and trigger texts rendered with a fixed boundary; a new test sdk/python/tests/test_context_golden.py produces and checks them (it writes them only when BOTHY_SDK_RECORD_CONTEXT=1 is set, like M2-29's fixtures, otherwise it compares). These two test files are the only change under sdk/python/; nothing under sdk/python/src changes.

  3. Package, entry points and dependencies (Ruling for docs/DECISIONS.md). package.json keeps "private": true and "type": "module" and gains "sideEffects": false, "files": ["dist"] and exports with three entries, each with types and import into dist/:

    • ".": everything except testing; Node.js only (it handles tokens and keys).
    • "./core": the browser-safe subset: generated types, tolerance layer, errors, components builders, renderContext, uuidv7, pages. A test proves that the module graph of dist/core.js imports no node: module and none of auth, invocation, gateway, webhooks, outbox (11 11.3: "browser only for the parts without secrets").
    • "./testing": FakeServer, signWebhook, scenario functions and doubles.

    Runtime dependencies, each declared as a compatible range from the tested version with the exact version in the lock (M2-23 note 11): openapi-fetch (M2-23), jose (11 11.3 names it with EdDSA, createLocalJWKSet and an algorithm allow list; MIT), ws (only testing imports it, because Node.js has a WebSocket client but no server). Everything else comes from the Node.js standard library: node:crypto for Ed25519, HMAC-SHA256, timingSafeEqual, SHA-256 and randomness; the global fetch and WebSocket. Reason: the fewest third-party packages in the security path (SEC-SC-1), and webhook verification uses the same primitives as G10 on the server (Ed25519 and HMAC of the standard library). npm run build runs tsc -p tsconfig.build.json (ESM, declarations, source maps, tests excluded, the generated core included); dist/ is never committed.

  4. Client, auth, retry, errors and deprecation. Extend createApiClient(options) of M2-23 without breaking its tests; new optional members: token, dpopKey (DpopKey or DpopSigner), timeoutMs (default sdk.http_timeout), retry (maxAttempts default sdk.retry_max_attempts, baseMs default sdk.retry_backoff_base, capMs default sdk.retry_backoff_cap, random, sleep), logger, fetch, userAgent (<app>/<version> (<platform>), 06 2 "Client identifier"). It passes openapi-fetch a wrapped fetch that per attempt:

    • sets Authorization: DPoP <token> and a fresh DPoP proof when a DPoP key is configured, else Authorization: Bearer <token> (06 3); a proof has header typ = dpop+jwt, alg EdDSA or ES256 (signature as 64-byte R || S), public jwk only; claims jti (random), htm, htu (without query and fragment), iat, ath (base64url SHA-256 of the token), nonce when known (08 7.3). loadDpopKey(path) reads a PKCS#8 PEM or a private JWK file; DpopSigner { alg; publicJwk; sign(data) } serves key stores; dpopThumbprint(jwk) gives the RFC 7638 jkt;
    • sets the header named by components.parameters.InvocationToken (Invocation-Token) to the bound run's newest token while a run binding is set, and never outside it (07 SR9). client.bindRun(runId, token, fn) runs fn with the binding held in an AsyncLocalStorage (Node entry only, never ./core); AgentLoop runs handleRun, onFollowup, onInteraction and the command handler of a command.invoked with run_id inside it and replaces the bound token after a renewal or a newer delivery (note 7). Ruling for docs/DECISIONS.md: AsyncLocalStorage, so two concurrent runs in one process never see each other's token; a test holds the header name equal to openapi.yaml;
    • limits the attempt with AbortSignal.timeout(timeoutMs); keeps the newest DPoP-Nonce of any response; on 401 use_dpop_nonce rebuilds the proof and repeats exactly once, outside the retry count (06 3.1 point 2);
    • retries only when the request is idempotent: method GET, HEAD, PUT or DELETE, or an Idempotency-Key header is present, or the operation is in IDEMPOTENT_EXEMPT_OPERATIONS, the table of every operation whose x-idempotency-exempt reason is agent_runtime, client_id or read_only (keyed METHOD /path/template; a test holds it equal to openapi.yaml, Ruling: these reasons promise an equal first answer per request or no state change, 06 7 point 3). Retried are network errors, timeouts, 5xx and 429; never another 4xx. Attempt n (n at least 1) waits a uniform random time in [sdk.retry_backoff_base, min(sdk.retry_backoff_cap, sdk.retry_backoff_base times two to the power n-1)], the reading of "full jitter between base and cap" that D-M2-26-7 fixed; a valid Retry-After (delta-seconds or HTTP-date) raises the wait to at least its value; at most sdk.retry_max_attempts attempts in total.

    ProblemError carries status, code (the raw string), reason, errors (FieldError), current, correlationId, instance, retryAfterMs; KNOWN_PROBLEM_CODES is held equal to x-problem-codes by a test; an unknown code keeps its string and is classified by status (06 5; R-API-04); isNotFound(err) says in its TSDoc that 404 means "not there or not visible". Error messages contain status, code and operation, never a body, a token or a proof. A response with Deprecation or Sunset makes one warning per METHOD schemaPath and client through logger.warn (default process.emitWarning with type DeprecationWarning) naming the operation and both header values (11 12). pages(fetchPage) iterates next_cursor pages (06 14 point 2).

  5. Gateway (gateway). GatewayConsumer options: url (default derived from the client's base URL as wss://<host>/api/v1/gateway; ws:// only for loopback hosts, so the FakeServer works without TLS), token, dpopKey, consumer (default default), protocolVersion (the value of info.x-protocol-versions), WebSocket constructor, random, clock, reconnect defaults sync.reconnect_backoff_base and sync.reconnect_backoff_cap.

    • After hello: identify {protocol_version, api_token, consumer, dpop} (proof with htm = GET, htu = HTTPS form of the gateway URL, nonce = dpop_nonce, ath), or resume {gateway_session_id, api_token, dpop, cursor} when the previous session ended less than resume_window_ms ago; 4006 after resume falls back to identify (04 6.3; M2-07 note 4).
    • Heartbeat every heartbeat_interval_ms, the first after a random fraction of it, heartbeat {last_seq} plus agent when a heartbeat source is attached (note 9); no heartbeat_ack within the interval closes the socket and reconnects. ready or resumed resets the backoff counter n; attempt n waits uniformly between 0 and min(sync.reconnect_backoff_cap, sync.reconnect_backoff_base times two to the power n) (04 6.5).
    • Close codes (Ruling for docs/DECISIONS.md, the agent reading of 04 6.4): stop without reconnecting and report {code, permanent: true} on 1000, 4004, 4007, 4011, 4012, 4015, 4016, 4017, 4018 (an API token cannot be refreshed by the SDK); 4008 waits the seconds named in the close reason when it is an integer, else backoff; 4013 reconnects after a uniform delay between 0 and sync.reconnect_backoff_base; every other code, also an unknown one, reconnects with backoff and tries resume first.
    • dispatch goes to the event handler decoded through M2-23's tolerance layer (unknown type: UnknownEvent, still acknowledgeable); cursor only advances last_seq; error frames go to onOpError with code and ref; unknown ops and fields are ignored and a JSON null member is dropped. The consumer never acknowledges by itself: ack(seq) sends ack {seq}. sendRunUpdate and sendAutocompleteResult send the ops run.update and command.autocomplete_result.
    • PollingConsumer offers the same handler interface over pollAgentEvents and ackAgentEvents (09 4.2).
  6. Invocation (invocation). JwksCache fetches getJwks through the client, keeps it for the max-age of Cache-Control (the server sends agent.jwks_max_age), reloads once for an unknown kid and at most once per adapter.jwks_refresh_min_interval; a token never adds a key. InvocationVerifier.check(event, token) returns accept, expired, unknown_kid, replayed or reject with reason invalid_invocation_token, billing_mode_mismatch or tool_class_mismatch, checking in the order of 09 4.5: header alg exactly EdDSA and typ exactly agent-invocation+jwt before any key lookup; signature with jose jwtVerify and algorithms: ["EdDSA"], issuer = configured instanceId, audience = configured agentId, clockTolerance = adapter.invocation_token_leeway; iat not in the future beyond the leeway (checked explicitly); exp passed with everything else valid gives expired; run_id, thread_id and the triggering object (message_id, interaction_id or command_id) equal the payload; jti new to the JtiStore (memory, entries until exp, replaceable); actualBillingMode(claims, event) returns personal, organization or unknown, and unknown counts as different from every target (10 7, RK-13); when allowList: true, sub must be in getAgentTriggerers, reloaded first when the token's permission_version is higher (17.7); loadedToolClass not above tool_class in the order chat < scoped < host. Ruling for docs/DECISIONS.md: a failed allow list gives invalid_invocation_token, because 09 4.5 allows only the three reasons. instanceId and agentId are required options, never read from discovery (09 4.5 point 4: "configured instance"). The token is never logged; logs carry at most a short prefix of the SHA-256 hash of the jti.

  7. AgentLoop (runs), the flow of 09 4.9 for every event with a delivery field invocation_token (agent.invocation, agent.followup, interaction.created, command.invoked):

    • unknown_kid: neither stored nor acknowledged (it comes again after agent.ack_timeout); replayed: acknowledged without action; reject: finishRun with state: failed and the reason when the event has a run_id and is not a command.invoked, then acknowledged; accept or expired: stored with its token in the Inbox, then acknowledged. An inbox entry with the same event id whose run has not started gets the newer token instead of a second entry (09 4.3, 04 15.2 point 4). After the check, a command.invoked (a control command, 09 10.4) goes to the handler of commands, and the loop never starts, resumes or ends a run because of it; it carries run_id exactly when the agent has an active run in the thread (05 section 5, R-AZ-25). Any other event for a run this loop holds in running goes to the handle's callbacks (onFollowup, onInteraction) instead of a start, and one for a run the loop put into waiting_input resumes it.
    • Start from the inbox with the newest token: an expired token is renewed first with renewInvocationToken (its Idempotency-Key stored in the entry, at most one renewal per run in flight, 09 4.5.1); then startRun (or resumeRun for a run in waiting_input, 04 15.3 point 5) with billing_mode_used, tool_class_used and the optional members of RunStart. 200 (also with Idempotent-Replay: true) marks the entry started and calls handleRun(run: RunHandle) inside the run binding of note 4; 403 invocation_token_invalid with expired renews and starts once more; with superseded the entry waits for a newer token; with invalid the loop finishes the run failed with invalid_invocation_token; 409 already_started, 409 billing_mode_mismatch, 409 tool_class_mismatch and 403 agent_invocation_denied drop the entry (the server already decided).
    • The runtime callback runs only after 200 (R-AG-07). After a restart the loop resumes entries that are not started (09 4.9 last paragraph). run.cancel_requested calls the cancel callbacks of the run's handle. FileInboxStore writes one JSON file per entry with mode 0600 in a directory of mode 0700 through write and rename; it never stores more than the event and its token.
  8. RunHandle and RunUpdater (runs). context() calls getRunContext. patch(ops) checks every path and op locally against the table of 09 5.3 and throws PatchPathError without a request for anything else, including /state; the updater coalesces queued ops (a later replace of the same path replaces the earlier one, other ops keep their order) and sends at most once per agent.run_update_min_interval over REST patchRun with base_version (Ruling for docs/DECISIONS.md: REST, because 409 base_version_conflict carries the document in current, while a gateway error frame has no document; sendRunUpdate stays available). 200 sets base_version to version; 409 base_version_conflict sets it to current.version and resends the pending ops once, then surfaces the error ("start over on 409", 11 11.2); any other problem stops the updater and surfaces. Helpers: step(...) adds /steps/<uuidv7> and replaces its status, title, detail, finished_at; followupDelivered(messageId) and followupAbsorbed(messageId); awaitInput(statusLine); reportSession(...) wraps reportAgentSession with its precondition header; finish(...) flushes pending ops, then sends the members of RunFinish, and a repeated equal end state is success. renderContext(context, { fileTexts, boundary }) returns { system, trigger } like M2-29's RenderedContext and produces the texts of note 2's golden files: one fixed system part separate from the trigger text, every element with author, role and trust, unverified content and every file content inside a marked data block behind the fixed notice, a transcript with the trust of its message (09 4.6, 17.6, ADR 0054). boundary defaults to 16 hex characters drawn per call with Web Crypto crypto.getRandomValues and is drawn again if the content contains it; it uses no node: import, so ./core stays clean. It is injectable only for tests, and the golden files are rendered with a fixed test boundary. Reason: an author cannot forge the end of a block without knowing the boundary (M2-29 Design note 11, ADR 0054).

  9. Other modules (each with the Python behaviour of M2-29; the defaults are the named keys):

    • components: builders button, select, form, approvalCard (the four buttons of 09 11.5 with required_capability and restricted_to; the button "Always allow" can be left out by option) and question (11.6); they validate component.max_per_message, component.custom_id_max_chars and component.form_max_fields before sending and produce MessageCreate bodies with kind: card; respond(...) and update(...) wrap respondToInteraction and updateComponent with the precondition each operation defines.
    • commands: CommandRegistry.reconcile(declared) on every ready: for each declared scope it lists listChannelCommands of the scope target and keeps entries with owner_principal_id equal to the agent; registers missing (registerCommand), updates changed (updateCommand), removes undeclared ones found in those scopes or recorded in its replaceable CommandStore from an earlier reconcile (removeCommand); it validates command.description_max_chars and command.max_per_principal first. Autocomplete: onAutocomplete(handler) answers command.autocomplete_requested through sendAutocompleteResult (REST answerAutocomplete without a gateway) with at most command.autocomplete_max_choices choices; the guard aborts the handler at the event's expires_at and then sends nothing. The TSDoc says a handler never calls a model (R-AG-14).
    • outbox: Outbox.sendMessage(threadId, body) generates client_msg_id (uuidv7()), stores the entry in the replaceable OutboxStore (MemoryOutboxStore, FileOutboxStore like the inbox) before sending, deletes it on 2xx (also Idempotent-Replay: true), keeps it on a transport error and resends it with the same id in flush(); 422 idempotency_key_reuse drops the entry and surfaces. Outbox.write(name, call) stores an Idempotency-Key before the first attempt for other writes.
    • heartbeat: buildHeartbeat(input) returns a Heartbeat body; details accepts only the members of agent_heartbeat.v1 (09 4.8) not covered by typed fields, and any other member is an error naming it; runtime and adapter names and versions, degraded_reasons and billing_evidence values must be codes or versions (letters, digits, ., _, +, -), so no path, URL or key fits. HeartbeatSender sends through the gateway heartbeat (member agent) or sendHeartbeat and keeps the interval_ms of the answer.
  10. Webhooks (webhooks, Rulings for docs/DECISIONS.md). Public API, each with TSDoc and an example:

    • parseWebhookPublicKey(value): whpk_ plus standard base64 of the DER SubjectPublicKeyInfo of an Ed25519 key (D-M2-26-11); another prefix, bad base64 or another key type throws WebhookConfigError. parseWebhookSecret(value): whsec_ plus standard base64 (D-M2-26-10); empty or malformed throws.
    • verifyWebhook({ body, headers, keys, secrets, toleranceMs, now }): body is the raw request body as Uint8Array or string (a string counts as its UTF-8 bytes; never parsed and serialized again); headers is a Headers object or a plain record (case-insensitive). Steps: the three headers present, else missing_header; webhook-timestamp an integer of Unix seconds, else invalid_timestamp; its distance to now() at most toleranceMs (default webhook.receiver_tolerance) in both directions, else timestamp_out_of_tolerance; no key and no secret configured gives no_trusted_key; the header is split at spaces, each entry at the first comma, entries with an unknown scheme or bad base64 are ignored; a v1a entry is tried against every key with crypto.verify, a v1 entry against every secret with HMAC-SHA256 and timingSafeEqual (length checked first); one valid entry returns { id, timestamp, scheme, event, syncEpoch } with event decoded by M2-23's decodeEvent; none gives signature_mismatch. Errors are WebhookVerificationError with code from exactly this list; messages never contain the body, a signature or a secret.
    • verifyWebhookRequest(request, options): the same for a Fetch API Request, reading the body once as bytes. It serves interactions_url requests as well (11 9).
    • WebhookKeyCache: fetches getWebhookKeys once, trusts every listed key (next, active, retiring), refreshes after the response's Cache-Control max-age (without the header: agent.jwks_max_age), and never refreshes because of an incoming request, so a forged request cannot make the app fetch. Update webhooks.md 3 point 3 with this rule ("reload the list after the max-age of the response; new keys appear in it beforehand as next").
    • Not in the SDK: storing seen webhook-id values (webhooks.md 3 point 4 needs the app's own storage for webhook.retry_max_age); the README says how an app does it. No Standard Webhooks library as dependency: the vectors were checked against the reference library and the standard library in G10 (M2-26).
  11. Testing (testing). FakeServer.start({ clock, ackTimeoutMs, tokenTtlMs, ... }) listens on 127.0.0.1 port 0 (REST and gateway) and returns { baseUrl, gatewayUrl, stop() }. It models the server rules the SDK depends on: agents and API tokens (optionally DPoP-bound, with nonces), consumer cursors with ack, inflight window and redelivery with redelivered: true after ackTimeoutMs, a fresh EdDSA token per delivery that replaces invocation_jti (claims of 05 5, published at getJwks), startRun, resumeRun, renewInvocationToken, awaitRunInput, finishRun with the answers of 09 4.5 and 4.5.1, patchRun with the path table and base_version, createMessage with client_msg_id, the command, component, heartbeat and autocomplete operations of note 9, getWebhookKeys and sendWebhook(url, envelope, { hmacSecrets }) signing with its own key. Every handler builds its body with the generated types, so tsc holds the response shapes. Fault injection per operation and count: store_then_502, store_late, rate_limit (429 with Retry-After), drop_connection; restart() keeps state and redelivers unacknowledged events with new tokens (AG-28). requests() records method, path and status only. signWebhook({ id, timestamp, body, seeds, secrets }) returns the webhook-signature value with v1a entries first in seed order and then v1 entries in the given order (D-M2-26-10). The FakeServer implements ScenarioServer (note 2), so the scenario functions run against it and, through the sdkserver client of note 12, against the real server.

  12. Scenarios and live runs (Ruling for docs/DECISIONS.md). test/scenarios.test.ts runs every scenario function of note 2 (at least AG-08 adapter side, AG-09, AG-12, AG-28) against the FakeServer. Real-server runs reuse M2-29's sdkserver unchanged. test/live/global-setup.ts (Vitest globalSetup) builds it once the way sdk/python/tests/realserver/conftest.py does, go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserver in services/server, and hands the binary path to the test files (Vitest provide, or the equivalent of the pinned version). test/live/sdkserver.ts runs it with -test.run '^TestSDKServer$' inside the test worker, because only the process that owns its stdin can drive it; it drives sdkserver over its JSON-lines protocol (start {config, agent_jkt, dir} with the same config as M2-29's fixture, trigger, forge_token, run, restart, stop; answers SDKSERVER <json>) and implements the TypeScript ScenarioServer on it (passTime sleeps real time). test/live/scenarios.live.test.ts starts it once in beforeAll and stops it in afterAll with stop and then by PID. The live files run inside npm test, so the root make test and make check run them. Without Docker or the Go tool chain they fail and never skip. Coverage (quality.coverage.typescript_min_percent) excludes test/live/. tools/ci/components.py gives sdk-typescript the prefix services/server/, as M2-29 did for sdk-python (test in test_components.py). No Go test is added. The live scenarios never call operations outside this ticket's dependency closure (Out of scope); the delay of AG-28 is a delay before startRun.

  13. typedoc gate (Ruling for docs/DECISIONS.md). typedoc.json: entry points src/index.ts, src/core.ts and src/testing/index.ts; validation with notExported, invalidLink and notDocumented true; treatValidationWarningsAsErrors: true; requiredToBeDocumented with every reflection kind the hand layer exports; the generated core outside the doc rule like in M2-23 note 8 (mark src/generated/** as external and exclude externals, or the equivalent option of the pinned version). Verify every option name and the CLI flag below against the documentation of the pinned typedoc. Script docs:check = typedoc --emit none; script lint = eslint . then npm run docs:check, so the root make lint fails on an undocumented export. Script docs writes the HTML reference to build/reference/. Pin typedoc exactly and add it to docs/quality/toolchain.md and to pinned_tools() of test_toolchain_doc.py.

  14. Reference in the documentation site (Ruling for docs/DECISIONS.md). The recipe docs runs npm --prefix sdk/typescript run docs before mkdocs build when sdk/typescript/package.json is tracked. The hook tools/docs/mkdocs_sdk_reference.py adds every file of sdk/typescript/build/reference/ in on_files as a generated static file under api/typescript-reference/ (File.generated with abs_src_path); with the package tracked and the directory missing or empty it raises PluginError naming the npm command (fail closed, 23 1 point 3); without the package it does nothing. The English page docs/api/sdk-typescript.md (nav next to the Python page) explains entry points, modules, the webhook verifier with an example and links the reference with the Markdown link text "API reference" and the relative target typescript-reference/index.html; MkDocs validates that link in the strict build because the hook makes the target part of the build. make linkcheck checks tracked files on disk, where the target does not exist, so its lychee call excludes exactly the URL pattern of docs/api/typescript-reference/ (check the form against --help of the pinned lychee); a test asserts the pattern matches nothing else. .github/workflows/docs.yml gets the actions/setup-node step of ci.yml (same SHA and Node line) and npm ci --prefix sdk/typescript --ignore-scripts --no-audit --no-fund --loglevel=error before make docs; make runners-check stays green.

  15. Configuration defaults. src/config.ts exports DEFAULTS with one member per key used by the SDK (sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap, adapter.invocation_token_leeway, adapter.jwks_refresh_min_interval, webhook.receiver_tolerance, agent.jwks_max_age, agent.run_update_min_interval, sync.reconnect_backoff_base, sync.reconnect_backoff_cap, component.max_per_message, component.custom_id_max_chars, component.form_max_fields, command.description_max_chars, command.autocomplete_max_choices, command.max_per_principal), each named by its key in TSDoc; test/config-defaults.test.ts reads docs/operations/configuration.md and holds every value equal to the "Default" column (durations parsed to milliseconds). Every option can be overridden by the caller.

  16. Rulings for docs/DECISIONS.md section 10, one row each with date, decision, reason and place: D-M2-30-1 parity, ported scenario functions and shared data (note 2); D-M2-30-2 entry points, jose, ws only in testing, standard library for webhooks and DPoP, dist/ (note 3); D-M2-30-3 per-attempt DPoP, nonce retry outside the count, idempotency table and backoff reading, run binding of Invocation-Token through AsyncLocalStorage (note 4); D-M2-30-4 close-code reactions and resume rule for agents (note 5); D-M2-30-5 verification order, required instanceId and agentId, allow-list failure as invalid_invocation_token (note 6); D-M2-30-6 inbox, acknowledgement and start outcomes (note 7); D-M2-30-7 patches over REST, local path check, one rebase (note 8); D-M2-30-8 command reconciliation per declared scope with a store (note 9); D-M2-30-9 webhook verifier API, error codes, trust-list refresh by max-age, no webhook-id store, no Standard Webhooks dependency (note 10); D-M2-30-10 FakeServer model and faults (note 11); D-M2-30-11 live runs against M2-29's unchanged sdkserver over its stdin protocol inside npm test, no Go test (note 12); D-M2-30-12 typedoc gate with the generated core outside the doc rule (note 13); D-M2-30-13 reference mount, linkcheck exclusion and docs workflow (note 14); D-M2-30-14 defaults held by a test (note 15).

Tests (write them first)

Unit tests, sdk/typescript/test/ (Vitest, run by the root make test; REST and gateway against FakeServer on 127.0.0.1 port 0 or a fake fetch; fake clock and seeded random where timing matters):

  • webhooks.vectors.test.ts (PLAN row: "signature verification against the test vectors from M2-26"): for every signature case of docs/api/webhook-test-vectors.json verifyWebhook accepts the body with the header value of the case, its public_key value(s) and its hmac_secrets, at now equal to its timestamp; each scheme alone (only the keys, only the secrets) accepts when the header has an entry of that scheme; the body with one byte changed, another webhook-id and another timestamp each give signature_mismatch; signWebhook reproduces the header value byte for byte from the case's seeds and secrets; for every derivation case, parseWebhookSecret('whsec_' + base64(bytes of derived_hex)) returns exactly the bytes of derived_hex (the string is built in memory and never written or printed), and every hmac_secrets value of the signature cases parses with parseWebhookSecret.
  • webhooks.test.ts: missing_header for each header; invalid_timestamp for abc, 1.5, empty; timestamps exactly webhook.receiver_tolerance before and after now accepted, one second beyond rejected with timestamp_out_of_tolerance; no_trusted_key; header with several spaces, an unknown scheme v2,... and bad base64 still verifies by its valid entry; a key of another type and a wrong prefix throw WebhookConfigError; a body parsed and serialized again fails (raw-body rule); a v1 entry of another length does not throw; verifyWebhookRequest with a Fetch Request carrying a signed command.invoked envelope (interactions_url) returns the decoded event and syncEpoch; an envelope of unknown type returns UnknownEvent; WebhookKeyCache fetches once, refreshes after max-age, never on a failed verification; a seeded set of random headers and bodies gives only WebhookVerificationError, never another exception; no error message contains the body or a secret.
  • invocation.test.ts (09 4.5 points 1 to 10): alg none, HS256 with the public key as secret and ES256 are reject before any key lookup; typ at+jwt is reject; an unknown kid reloads the JWKS once, a second unknown kid within adapter.jwks_refresh_min_interval does not reload and gives unknown_kid; foreign iss and foreign aud are reject with invalid_invocation_token; exp passed by more than adapter.invocation_token_leeway is expired, by less is accept; iat in the future beyond the leeway is reject; payload mismatch of run_id, thread_id or the triggering object is reject; the same jti twice is replayed; actual mode different or unknown is billing_mode_mismatch; host against scoped is tool_class_mismatch, chat against scoped is accept; a higher permission_version reloads the allow list and a missing sub is invalid_invocation_token; no log line contains the token.
  • azp6.test.ts (AZ-P6 for the SDK): an AgentLoop with a runtime spy receives agent.invocation events from the FakeServer with a missing token, a wrong signature, a foreign aud, an expired token, a repeated jti, a different billing_mode and a higher tool class: the spy is never called; the FakeServer records finishRun with invalid_invocation_token, billing_mode_mismatch or tool_class_mismatch where 09 4.5 says so, a renewal for the expired token, nothing for the repeated jti; with a valid token the spy is called once, after 200 on startRun.
  • scenarios.test.ts: the scenario names of testing in camelCase equal the list of sdk/testdata/scenarios/README.md; every scenario function of note 2 against the FakeServer, at least AG-08 adapter side (no start; same tokens at renewInvocationToken give 403 invocation_token_invalid with reason: invalid), AG-09 (two loops with their own inboxes and connections: exactly one startRun succeeds), AG-12 (a raw patchRun on /state gives 400 validation_failed with errors[].code patch_path_forbidden; an old base_version gives 409 base_version_conflict with current, after which the updater's next patch succeeds; RunUpdater.patch on /state throws PatchPathError without a request), AG-28 (start delayed beyond the ack timeout and the token TTL: the event was acknowledged after the inbox write, so no redelivery before the restart; the loop renews; startRun with the expired token gives 403 invocation_token_invalid with reason: expired, with a replaced token reason: superseded; after restart() the redelivery's newer token replaces the stored one and the run starts exactly once).
  • loop.test.ts: each start outcome of note 7 (replay 200, superseded waits, invalid finishes, 409 already_started, 403 agent_invocation_denied, renewal at most once in flight); an unknown_kid event is not acknowledged; restart resumes a not started inbox entry; FileInboxStore modes 0700 and 0600; run.cancel_requested calls the cancel callback; a request made in handleRun carries the run's token in Invocation-Token, after a renewal the renewed one.
  • runs.test.ts: allowed and forbidden paths of 09 5.3 per op; coalescing and at most one send per agent.run_update_min_interval; one rebase on 409 base_version_conflict; steps with UUIDv7 ids; follow-up states only forward; finish flushes first and a repeated end state is success.
  • context.test.ts: every golden pair of sdk/testdata/context/ renders byte for byte, among them a message of an author without trigger, another agent, an import, a file content of the trigger's upload (marked data) and a transcript of the trigger's voice message (mark trigger); unverified text appears only inside the marked block; without boundary two calls use different boundaries; a content containing a forged end marker does not close the block; the trigger text never appears in system.
  • gateway.test.ts: identify fields and the DPoP proof (htm, htu, nonce of hello, ath); first heartbeat offset from the seeded random; missing heartbeat_ack reconnects; resume within resume_window_ms, identify after it and after 4006; one case per close code of note 5 (stop or reconnect, the 4008 wait); backoff bounds per attempt; unknown op ignored; null member dropped; PollingConsumer with the same handler.
  • client.test.ts: Bearer and DPoP schemes; proof claims and 64-byte ES256 signature; 401 use_dpop_nonce retried exactly once with the new nonce; IDEMPOTENT_EXEMPT_OPERATIONS equals the operations of openapi.yaml with reasons agent_runtime, client_id and read_only; a plain POST without key is never retried; 503 unavailable and 429 rate_limited with Retry-After wait at least that long; backoff bounds; sdk.retry_max_attempts respected; timeout; KNOWN_PROBLEM_CODES equals x-problem-codes; an unknown code is classified by status; one deprecation warning per operation; the Invocation-Token header only inside bindRun, its name equal to components.parameters.InvocationToken of openapi.yaml, and two concurrent runs never see each other's token.
  • outbox.test.ts (with the faults store_then_502 and store_late): the resend uses the same client_msg_id and gets the stored message; 422 idempotency_key_reuse drops the entry; Outbox.write keeps its key across retries. components.test.ts, commands.test.ts (reconcile creates, updates and removes; autocomplete guard sends nothing after expires_at; at most command.autocomplete_max_choices choices), heartbeat.test.ts (unknown details member, a path in runtime.version and a URL in degraded_reasons rejected).
  • fake-server.test.ts: rate_limit and drop_connection faults; redelivery with redelivered: true; restart(); requests() holds no header values.
  • packaging.test.ts (after npm run build): the three entries import; dist/core.js imports no node: module and none of the secret-handling modules; config-defaults.test.ts (note 15); types.test.ts: no exported type or interface of the hand layer has the name of a schema under components.schemas of openapi.yaml (ADR 0026 "Confirmation").
  • M2-23's tolerance.test.ts and generated.test.ts stay green (G4, G5).

Live, sdk/typescript/test/live/scenarios.live.test.ts (part of npm test, so the root make test and make check run it; against M2-29's sdkserver, note 12): the same AG-08 adapter side, AG-09, AG-12 and AG-28 scenarios against the real server, with the same expected answers; plus one webhook check if M2-29's world has an app subscription, otherwise none (the server side of G10 is M2-26's).

Repository tools:

  • tools/ci/tests/test_sdk_typescript_doc_gate.py (PLAN row: "test with a deliberately undocumented export"): copy sdk/typescript without node_modules, dist and build into a temporary directory with a link to the real node_modules, append export const undocumentedExample = 1; to the copy's src/index.ts, run npm run lint there: non-zero exit, output names undocumentedExample; the unchanged copy passes; a symbol only in src/generated/ is not reported. The real working tree stays untouched (git status --porcelain before and after).
  • tools/docs/tests/test_mkdocs_sdk_reference.py: the hook mounts every file of a temporary reference directory under api/typescript-reference/; raises PluginError with the npm command when the package is tracked and the directory is empty; does nothing when the package is not tracked; the lychee exclusion of the linkcheck recipe matches the reference path and no other tracked link.
  • tools/docs/tests/test_toolchain_doc.py: the typedoc row matches the lock.
  • tools/ci/tests/test_components.py: services/server/internal/runs/app/patch.go selects sdk-typescript as well as server and sdk-python.

Python, sdk/python/tests/ (note 2): test_context_golden.py (every golden pair of sdk/testdata/context/ equals render_context with the fixed test boundary) and test_scenario_list.py (the scenario names of bothy_sdk.testing.scenarios equal the list of sdk/testdata/scenarios/README.md).

Test IDs of the plan row: AG-08 (adapter side), AG-09, AG-12 and AG-28 from 09 16.2 (scenarios.test.ts and the live run); AZ-P6 from 07 11.2 (azp6.test.ts); G4 and G5 of 06 15 stay green through M2-23's tests; the vector cases are those of G10 (06 15).

Commands that must pass, from the repository root:

make -C sdk/typescript generate-check
npm --prefix sdk/typescript run build
npm --prefix sdk/typescript run lint
npm --prefix sdk/typescript test
cd tools && uv run --locked --quiet pytest ci/tests/test_sdk_typescript_doc_gate.py docs/tests/test_mkdocs_sdk_reference.py docs/tests/test_toolchain_doc.py ci/tests/test_components.py
make api-clients
make fmt-check
make lint
make typecheck
make test
make coverage
make docs
make linkcheck
make licenses
make osv
make runners-check
make check

Acceptance criteria

  • sdk/typescript has the hand-layer modules of 11 11.2 on the unchanged generated core, with the behaviour of M2-29 on every shared file; no hand type repeats a generated schema.
  • AG-08 (adapter side), AG-09, AG-12 and AG-28 pass against the FakeServer and against the real server (M2-29's sdkserver) inside make check.
  • AZ-P6: no case of 07 11.2 starts the runtime; each ends as 09 4.5 prescribes.
  • Every signature case of docs/api/webhook-test-vectors.json verifies, signWebhook reproduces each header byte for byte, changed bodies, IDs and timestamps fail, and the time window follows webhook.receiver_tolerance; the verifier serves interactions_url requests.
  • The context rendering matches the shared golden files, including file contents as data and transcripts with their message's mark; without an injected boundary each call draws a new one, and a forged end marker does not close a block.
  • During a run every SDK request carries Invocation-Token; outside a run none does.
  • typedoc reports an undocumented export as an error and fails npm run lint and make lint (deliberate-export test); the reference is built in make docs and linked from docs/api/sdk-typescript.md.
  • npm run build produces dist/ with the entries ., ./core and ./testing; ./core uses nothing that holds secrets.
  • Retry, DPoP, outbox, gateway reconnect and deprecation behave as notes 4, 5 and 9 say, with the defaults of the named keys held by a test.
  • License and OSV gates pass without a change to the allowlist; nothing is published.
  • 11, 23, webhooks.md, ADR 0026, quality pages, READMEs, CHANGELOGs and DECISIONS are updated.
  • 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 sdk and sdk/typescript (and of sdk/testdata/scenarios and sdk/testdata/context) written or updated in English: entry points, modules, Node line, webhook verification with example, FakeServer and faults, live runs, reference build.
  • Rulings of note 16 recorded in docs/DECISIONS.md (one row each with reason).
  • sdk/typescript/CHANGELOG.md and the root CHANGELOG.md entries under 'Unreleased'.
  • ADR 0026 "Confirmation" names the tests without changing the decision; a new 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), with attention to the M2 "Red team" items "forging or reusing the invocation token" and "faking the reported mode" (name azp6.test.ts, invocation.test.ts and the AG-28 scenario in the request), to requests of a run without Invocation-Token ("confused deputy through the server API", 07 SR9) and to forged webhooks (T-PL-01); 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.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (for example the modules whose server operations are not enabled yet and were tested only against the FakeServer, browsers other than the import check of ./core).
  5. Follow-ups that could not be done in this scope, each with its reason; the pinned versions of jose, ws and typedoc; every difference to M2-23, M2-26 and M2-29 names or behaviour (D-W-100, note 2); spec gaps found, among them the missing operation to list or reconcile an agent's own commands in one request (09 10.2).

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