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 (headerInvocation-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 harnessservices/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 undersdk/python/,sdk/testdata/andservices/server/test/(the scenario functions and doubles ofbothy_sdk.testing,render_contextwith its tests, the live driver that runs the Python SDK against the real server, that isservices/server/test/sdkserverandsdk/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 keyquality.coverage.typescript_min_percent);docs/briefs/M2-26-webhooks-outgoing.md(Design notes 11 and 12: signing set, header order,whsec_andwhpk_encodings, the vector filedocs/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,resumeof agent sessions),M2-08-invocation-delivery.md(a fresh token per delivery),M2-09-run-lifecycle.md(answers ofstartRun,resumeRun,renewInvocationToken,finishRun),M2-10-run-document-live-box.md(patchRun, allowed paths,409 base_version_conflictwithcurrent),M2-14-acting-for.md(headerInvocation-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_urlis "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 rowswebhook.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 thereasontable, 4.5.1, 4.6 "Run context" withtrust, 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 rowsadapter.invocation_token_leeway,adapter.jwks_refresh_min_interval,component.*,command.*.docs/spec/04-sync-protocol.md(core contract, read only): 2.2 (delivery fieldsinvocation_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 "Nonull", "Unknown values", "Client identifier"); 3 and 3.1 "DPoP"; 5 "Errors"; 6 "Pagination"; 7 "Idempotency" (the generated list ofx-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 invocationagent.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: operationsgetDiscovery,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; schemasProblem(withx-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); thewebhooks:section.docs/api/asyncapi.yaml: channelgateway(messages andx-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 rowsD-M2-23-<n>,D-M2-26-10toD-M2-26-12andD-M2-29-<n>; section 10 "Rulings of the build milestones" (row formatD-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: rowssdk.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(rowslint,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(jobgates: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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- 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"). - 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").
- 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 repeatedjti, a differentbilling_modeor a higher tool class (07 11.2; 09 4.5; T-TK-05). - One hand-layer function assembles the run context with trust marks as marked data blocks:
unverifiedcontent 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). - 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-timestampwithinwebhook.receiver_toleranceof the own clock; every entry ofwebhook-signaturetried against the trusted keys and secrets, one valid entry suffices;v1akeys come fromgetWebhookKeys(whpk_) as a trust list and never from the request;v1secrets arewhsec_values compared in constant time (webhooks.md 2, 2.1, 3; 11 7, 9, 11.2; ADR 0016; R-API-14); every case ofdocs/api/webhook-test-vectors.jsonverifies (PLAN row M2-30, D-M2-26-12). - 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
kidand at most once peradapter.jwks_refresh_min_interval, the allow listEdDSA,typ = agent-invocation+jwt,iss,aud, time withadapter.invocation_token_leeway, ajtistore and a callback for the actual mode (11 11.2 rowinvocation, 11 11.3). - 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_invalidis handled perreason(expired: renew, start once more;superseded: wait for the newer token;invalid: finishfailedwithinvalid_invocation_token);409 already_startedstarts nothing; the runtime starts only after200(09 4.3, 4.5, 4.9; 04 15.2, 15.3; R-AG-07, R-AG-24, R-AG-25). - The gateway client follows 04:
hello,identifywithapi_token,consumerand a DPoP proof when the token is bound,resume, heartbeat with random offset,ackwindow, reconnect with full jitter between 0 andmin(sync.reconnect_backoff_cap, sync.reconnect_backoff_base * 2^n), the reaction per close code of 04 6.4 (4015ends without reconnecting); unknown ops, fields, event types and close codes are tolerated and a received JSONnullcounts as absent (04 6.1 to 6.5, 15.1, 15.2; 06 2; ADR 0053). - Retries only for idempotent operations: network errors, timeouts,
5xxand429withRetry-After; full jitter betweensdk.retry_backoff_baseandsdk.retry_backoff_cap; at mostsdk.retry_max_attemptsattempts; each request limited bysdk.http_timeout(11 11.2 rowretry, 11 16). - The outbox stores
client_msg_idbefore sending (replaceable store) and usesIdempotency-Keyfor other writes (11 11.2; 04 5; 06 7). - Errors are typed after the problem format; a
404means "not there or not visible" and is never treated as "definitely missing"; unknown problem codes are handled by status (11 11.2; 06 5). - DPoP proofs with a key from a file or a key store,
DPoP-Noncekept and one retry on401 use_dpop_nonce(06 3.1; 08 7.3); the gateway proof uses the nonce ofhello(04 6.3 point 3). - One warning per operation when the server sends
DeprecationorSunset(11 12; 06 13 point 4). - The heartbeat building block for
agent_heartbeat.v1rejects fields that could carry secrets (11 11.2 rowheartbeat; 09 4.8). - 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).
- ESM for the active LTS lines of Node.js; in a browser only the parts without secrets (11 11.3).
- 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). - Dependencies locked with checksums; license and vulnerability gates green without a change to the allowlist (SEC-SC-1, T-SC-01).
- Tokens, DPoP proofs, secrets and message content never appear in logs, error messages or test output (SEC-LOG-2; 04 2.2 rule 3).
- Nothing is published to npm and no name is reserved (OQ-17).
- 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 5bind_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 anindex.tsthat 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,InboxwithMemoryInboxStoreandFileInboxStore, 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 inscenarios.ts, note 11); plussrc/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 todist/),sdk/typescript/typedoc.json(note 13).- Tests of the section "Tests" under
sdk/typescript/test/andsdk/typescript/test/live/, withtest/live/global-setup.tsandtest/live/sdkserver.ts(note 12). tools/docs/mkdocs_sdk_reference.py(MkDocs hook, note 14) withtools/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.mdnext to the Python page of M2-29 (note 14). sdk/testdata/scenarios/README.md(English: the steps and expected outcomes of each scenario) andsdk/testdata/context/(input JSON, expectedsystemandtriggertexts, EnglishREADME.md) (note 2).sdk/python/tests/test_context_golden.pyandsdk/python/tests/test_scenario_list.py(note 2).
Change:
sdk/typescript/package.jsonandpackage-lock.json: dependenciesjose,ws(note 3), dev dependenciestypedoc,@types/ws; scriptsbuild,docs,docs:check,lint(eslintthennpm run docs:check);exports,files,sideEffects(note 3).sdk/typescript/src/index.ts, the module ofcreateApiClient(note 4),vitest.config.ts(globalSetuptest/live/global-setup.ts, coverage excludestest/live/, note 12),eslint.config.mjsand.prettierignore(ignoredist/andbuild/)..gitignore:sdk/typescript/dist/andsdk/typescript/build/.sonar-project.properties: exclude both directories if they are not excluded already.Makefile(root): recipedocsbuilds the typedoc reference before MkDocs whensdk/typescript/package.jsonis tracked; recipelinkcheckexcludes the mount path of the generated reference from lychee (note 14). No new target.mkdocs.yml: hooktools/docs/mkdocs_sdk_reference.pyand the nav entry ofdocs/api/sdk-typescript.mdin the section where M2-29 put the Python page..github/workflows/docs.yml: Node setup and SDK install beforemake docs(note 14).tools/docs/tests/test_toolchain_doc.pyanddocs/quality/toolchain.md(rowtypedoc);docs/quality/gates.md(rowslint,docs,linkcheck).tools/ci/components.pyandtools/ci/tests/test_components.py:sdk-typescriptalso owns the prefixservices/server/, as M2-29 did forsdk-python, so a server change runs the live files in CI (note 12).services/server/test/sdkserver/README.md: one sentence thatsdk/typescript/test/live/drives it as well (note 12); nothing else underservices/server/.docs/spec/11-extensions.md: 11.2 (the TypeScript entry points and thatwebhooksverifiesv1aandv1), 11.3 (TypeScript bullet: JOSE withjose, webhooks and DPoP with the Node.js standard library,wsonly in the FakeServer);docs/spec/23-quality.md"Documentation in code" (TypeScript row:validation.notDocumentedas error in the scriptlint, reference built inmake docs);docs/api/webhooks.md3 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; thenmake adr-index.- No change under
sdk/python/except the two new test files named under "Create" (note 2); no change undersdk/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.mdand rootCHANGELOG.mdunder "Unreleased",docs/DECISIONS.mdsection 10 rowsD-M2-30-1and 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 withrun_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-idat 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-buildof 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 ofAGENTS.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.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0 (P-10); PostgreSQL runs only as a throwaway container on a random port inside M2-29'ssdkserver(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 -dor 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). Everywhsec_value in tests carries the markerEXAMPLE(gitleaks stopword); never weaken.gitleaks.toml. - Dependencies pinned exactly in the lock; dev tools exact in
package.json; nolatest, no unpinnednpx. Do not extendquality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop withVerdict: 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¶
-
Preconditions, before writing code. Stop with
Verdict: BLOCKED <reason>when one of these holds on the base branch:docs/api/webhook-test-vectors.jsonis missing or has no signature case; M2-29 merged nosdkserver(services/server/test/sdkserverwith its stdin protocol) or no run of its scenarios against it insidemake check(there is no real server to run the TypeScript scenarios against, and building a second live mechanism would split the contract);components.parameters.InvocationTokenis missing fromdocs/api/openapi.yaml(Verdict: BLOCKED dependency M2-14 missing; M2-14 is in this closure through M2-29);make -C sdk/typescript generate-checkormake api-clientsis red. Otherwise continue. -
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) anddocs/api/webhook-test-vectors.jsonare 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 withVerdict: BLOCKED <reason>. Port M2-29's five scenario functions step for step tosdk/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 TypeScriptScenarioServerwith the members of M2-29's protocol, which the FakeServer and thesdkserverclient 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 inscenarios.test.ts).sdk/testdata/context/holds input JSON (aRunContextbody and thefile_texts) and the expectedsystemandtriggertexts rendered with a fixedboundary; a new testsdk/python/tests/test_context_golden.pyproduces and checks them (it writes them only whenBOTHY_SDK_RECORD_CONTEXT=1is set, like M2-29's fixtures, otherwise it compares). These two test files are the only change undersdk/python/; nothing undersdk/python/srcchanges. -
Package, entry points and dependencies (Ruling for docs/DECISIONS.md).
package.jsonkeeps"private": trueand"type": "module"and gains"sideEffects": false,"files": ["dist"]andexportswith three entries, each withtypesandimportintodist/:".": everything excepttesting; Node.js only (it handles tokens and keys)."./core": the browser-safe subset: generated types, tolerance layer, errors,componentsbuilders,renderContext,uuidv7,pages. A test proves that the module graph ofdist/core.jsimports nonode:module and none ofauth,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,createLocalJWKSetand an algorithm allow list; MIT),ws(onlytestingimports it, because Node.js has a WebSocket client but no server). Everything else comes from the Node.js standard library:node:cryptofor Ed25519, HMAC-SHA256,timingSafeEqual, SHA-256 and randomness; the globalfetchandWebSocket. 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 buildrunstsc -p tsconfig.build.json(ESM, declarations, source maps, tests excluded, the generated core included);dist/is never committed. -
Client, auth, retry, errors and deprecation. Extend
createApiClient(options)of M2-23 without breaking its tests; new optional members:token,dpopKey(DpopKeyorDpopSigner),timeoutMs(defaultsdk.http_timeout),retry(maxAttemptsdefaultsdk.retry_max_attempts,baseMsdefaultsdk.retry_backoff_base,capMsdefaultsdk.retry_backoff_cap,random,sleep),logger,fetch,userAgent(<app>/<version> (<platform>), 06 2 "Client identifier"). It passes openapi-fetch a wrappedfetchthat per attempt:- sets
Authorization: DPoP <token>and a freshDPoPproof when a DPoP key is configured, elseAuthorization: Bearer <token>(06 3); a proof has headertyp = dpop+jwt,algEdDSAorES256(signature as 64-byteR || S), publicjwkonly; claimsjti(random),htm,htu(without query and fragment),iat,ath(base64url SHA-256 of the token),noncewhen 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 7638jkt; - 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)runsfnwith the binding held in anAsyncLocalStorage(Node entry only, never./core);AgentLooprunshandleRun,onFollowup,onInteractionand the command handler of acommand.invokedwithrun_idinside 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 newestDPoP-Nonceof any response; on401 use_dpop_noncerebuilds 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,PUTorDELETE, or anIdempotency-Keyheader is present, or the operation is inIDEMPOTENT_EXEMPT_OPERATIONS, the table of every operation whosex-idempotency-exemptreason isagent_runtime,client_idorread_only(keyedMETHOD /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,5xxand429; never another4xx. Attempt n (n at least 1) waits a uniform random time in [sdk.retry_backoff_base, min(sdk.retry_backoff_cap,sdk.retry_backoff_basetimes two to the power n-1)], the reading of "full jitter between base and cap" that D-M2-26-7 fixed; a validRetry-After(delta-seconds or HTTP-date) raises the wait to at least its value; at mostsdk.retry_max_attemptsattempts in total.
ProblemErrorcarriesstatus,code(the raw string),reason,errors(FieldError),current,correlationId,instance,retryAfterMs;KNOWN_PROBLEM_CODESis held equal tox-problem-codesby a test; an unknown code keeps its string and is classified by status (06 5; R-API-04);isNotFound(err)says in its TSDoc that404means "not there or not visible". Error messages contain status, code and operation, never a body, a token or a proof. A response withDeprecationorSunsetmakes one warning perMETHOD schemaPathand client throughlogger.warn(defaultprocess.emitWarningwith typeDeprecationWarning) naming the operation and both header values (11 12).pages(fetchPage)iteratesnext_cursorpages (06 14 point 2). - sets
-
Gateway (
gateway).GatewayConsumeroptions:url(default derived from the client's base URL aswss://<host>/api/v1/gateway;ws://only for loopback hosts, so the FakeServer works without TLS),token,dpopKey,consumer(defaultdefault),protocolVersion(the value ofinfo.x-protocol-versions),WebSocketconstructor,random,clock, reconnect defaultssync.reconnect_backoff_baseandsync.reconnect_backoff_cap.- After
hello:identify {protocol_version, api_token, consumer, dpop}(proof withhtm = GET,htu= HTTPS form of the gateway URL,nonce=dpop_nonce,ath), orresume {gateway_session_id, api_token, dpop, cursor}when the previous session ended less thanresume_window_msago;4006afterresumefalls back toidentify(04 6.3; M2-07 note 4). - Heartbeat every
heartbeat_interval_ms, the first after a random fraction of it,heartbeat {last_seq}plusagentwhen a heartbeat source is attached (note 9); noheartbeat_ackwithin the interval closes the socket and reconnects.readyorresumedresets the backoff counter n; attempt n waits uniformly between 0 and min(sync.reconnect_backoff_cap,sync.reconnect_backoff_basetimes 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}on1000,4004,4007,4011,4012,4015,4016,4017,4018(an API token cannot be refreshed by the SDK);4008waits the seconds named in the close reason when it is an integer, else backoff;4013reconnects after a uniform delay between 0 andsync.reconnect_backoff_base; every other code, also an unknown one, reconnects with backoff and triesresumefirst. dispatchgoes to the event handler decoded through M2-23's tolerance layer (unknown type:UnknownEvent, still acknowledgeable);cursoronly advanceslast_seq;errorframes go toonOpErrorwith code andref; unknown ops and fields are ignored and a JSONnullmember is dropped. The consumer never acknowledges by itself:ack(seq)sendsack {seq}.sendRunUpdateandsendAutocompleteResultsend the opsrun.updateandcommand.autocomplete_result.PollingConsumeroffers the same handler interface overpollAgentEventsandackAgentEvents(09 4.2).
- After
-
Invocation (
invocation).JwksCachefetchesgetJwksthrough the client, keeps it for themax-ageofCache-Control(the server sendsagent.jwks_max_age), reloads once for an unknownkidand at most once peradapter.jwks_refresh_min_interval; a token never adds a key.InvocationVerifier.check(event, token)returnsaccept,expired,unknown_kid,replayedorrejectwithreasoninvalid_invocation_token,billing_mode_mismatchortool_class_mismatch, checking in the order of 09 4.5: headeralgexactlyEdDSAandtypexactlyagent-invocation+jwtbefore any key lookup; signature withjosejwtVerifyandalgorithms: ["EdDSA"],issuer= configuredinstanceId,audience= configuredagentId,clockTolerance=adapter.invocation_token_leeway;iatnot in the future beyond the leeway (checked explicitly);exppassed with everything else valid givesexpired;run_id,thread_idand the triggering object (message_id,interaction_idorcommand_id) equal the payload;jtinew to theJtiStore(memory, entries untilexp, replaceable);actualBillingMode(claims, event)returnspersonal,organizationorunknown, andunknowncounts as different from every target (10 7, RK-13); whenallowList: true,submust be ingetAgentTriggerers, reloaded first when the token'spermission_versionis higher (17.7);loadedToolClassnot abovetool_classin the orderchat<scoped<host. Ruling for docs/DECISIONS.md: a failed allow list givesinvalid_invocation_token, because 09 4.5 allows only the three reasons.instanceIdandagentIdare 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 thejti. -
AgentLoop(runs), the flow of 09 4.9 for every event with a delivery fieldinvocation_token(agent.invocation,agent.followup,interaction.created,command.invoked):unknown_kid: neither stored nor acknowledged (it comes again afteragent.ack_timeout);replayed: acknowledged without action;reject:finishRunwithstate: failedand the reason when the event has arun_idand is not acommand.invoked, then acknowledged;acceptorexpired: stored with its token in theInbox, then acknowledged. An inbox entry with the same eventidwhose run has not started gets the newer token instead of a second entry (09 4.3, 04 15.2 point 4). After the check, acommand.invoked(a control command, 09 10.4) goes to the handler ofcommands, and the loop never starts, resumes or ends a run because of it; it carriesrun_idexactly 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 inrunninggoes to the handle's callbacks (onFollowup,onInteraction) instead of a start, and one for a run the loop put intowaiting_inputresumes it.- Start from the inbox with the newest token: an expired token is renewed first with
renewInvocationToken(itsIdempotency-Keystored in the entry, at most one renewal per run in flight, 09 4.5.1); thenstartRun(orresumeRunfor a run inwaiting_input, 04 15.3 point 5) withbilling_mode_used,tool_class_usedand the optional members ofRunStart.200(also withIdempotent-Replay: true) marks the entry started and callshandleRun(run: RunHandle)inside the run binding of note 4;403 invocation_token_invalidwithexpiredrenews and starts once more; withsupersededthe entry waits for a newer token; withinvalidthe loop finishes the runfailedwithinvalid_invocation_token;409 already_started,409 billing_mode_mismatch,409 tool_class_mismatchand403 agent_invocation_denieddrop 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_requestedcalls the cancel callbacks of the run's handle.FileInboxStorewrites one JSON file per entry with mode0600in a directory of mode0700through write and rename; it never stores more than the event and its token.
-
RunHandleandRunUpdater(runs).context()callsgetRunContext.patch(ops)checks every path and op locally against the table of 09 5.3 and throwsPatchPathErrorwithout a request for anything else, including/state; the updater coalesces queued ops (a laterreplaceof the same path replaces the earlier one, other ops keep their order) and sends at most once peragent.run_update_min_intervalover RESTpatchRunwithbase_version(Ruling for docs/DECISIONS.md: REST, because409 base_version_conflictcarries the document incurrent, while a gatewayerrorframe has no document;sendRunUpdatestays available).200setsbase_versiontoversion;409 base_version_conflictsets it tocurrent.versionand resends the pending ops once, then surfaces the error ("start over on409", 11 11.2); any other problem stops the updater and surfaces. Helpers:step(...)adds/steps/<uuidv7>and replaces itsstatus,title,detail,finished_at;followupDelivered(messageId)andfollowupAbsorbed(messageId);awaitInput(statusLine);reportSession(...)wrapsreportAgentSessionwith its precondition header;finish(...)flushes pending ops, then sends the members ofRunFinish, and a repeated equal end state is success.renderContext(context, { fileTexts, boundary })returns{ system, trigger }like M2-29'sRenderedContextand 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,unverifiedcontent 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).boundarydefaults to 16 hex characters drawn per call with Web Cryptocrypto.getRandomValuesand is drawn again if the content contains it; it uses nonode:import, so./corestays 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). -
Other modules (each with the Python behaviour of M2-29; the defaults are the named keys):
components: buildersbutton,select,form,approvalCard(the four buttons of 09 11.5 withrequired_capabilityandrestricted_to; the button "Always allow" can be left out by option) andquestion(11.6); they validatecomponent.max_per_message,component.custom_id_max_charsandcomponent.form_max_fieldsbefore sending and produceMessageCreatebodies withkind: card;respond(...)andupdate(...)wraprespondToInteractionandupdateComponentwith the precondition each operation defines.commands:CommandRegistry.reconcile(declared)on everyready: for each declared scope it listslistChannelCommandsof the scope target and keeps entries withowner_principal_idequal to the agent; registers missing (registerCommand), updates changed (updateCommand), removes undeclared ones found in those scopes or recorded in its replaceableCommandStorefrom an earlier reconcile (removeCommand); it validatescommand.description_max_charsandcommand.max_per_principalfirst. Autocomplete:onAutocomplete(handler)answerscommand.autocomplete_requestedthroughsendAutocompleteResult(RESTanswerAutocompletewithout a gateway) with at mostcommand.autocomplete_max_choiceschoices; the guard aborts the handler at the event'sexpires_atand then sends nothing. The TSDoc says a handler never calls a model (R-AG-14).outbox:Outbox.sendMessage(threadId, body)generatesclient_msg_id(uuidv7()), stores the entry in the replaceableOutboxStore(MemoryOutboxStore,FileOutboxStorelike the inbox) before sending, deletes it on2xx(alsoIdempotent-Replay: true), keeps it on a transport error and resends it with the same id inflush();422 idempotency_key_reusedrops the entry and surfaces.Outbox.write(name, call)stores anIdempotency-Keybefore the first attempt for other writes.heartbeat:buildHeartbeat(input)returns aHeartbeatbody;detailsaccepts only the members ofagent_heartbeat.v1(09 4.8) not covered by typed fields, and any other member is an error naming it;runtimeandadapternames and versions,degraded_reasonsandbilling_evidencevalues must be codes or versions (letters, digits,.,_,+,-), so no path, URL or key fits.HeartbeatSendersends through the gatewayheartbeat(memberagent) orsendHeartbeatand keeps theinterval_msof the answer.
-
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 throwsWebhookConfigError.parseWebhookSecret(value):whsec_plus standard base64 (D-M2-26-10); empty or malformed throws.verifyWebhook({ body, headers, keys, secrets, toleranceMs, now }):bodyis the raw request body asUint8Arrayor string (a string counts as its UTF-8 bytes; never parsed and serialized again);headersis aHeadersobject or a plain record (case-insensitive). Steps: the three headers present, elsemissing_header;webhook-timestampan integer of Unix seconds, elseinvalid_timestamp; its distance tonow()at mosttoleranceMs(defaultwebhook.receiver_tolerance) in both directions, elsetimestamp_out_of_tolerance; no key and no secret configured givesno_trusted_key; the header is split at spaces, each entry at the first comma, entries with an unknown scheme or bad base64 are ignored; av1aentry is tried against every key withcrypto.verify, av1entry against every secret with HMAC-SHA256 andtimingSafeEqual(length checked first); one valid entry returns{ id, timestamp, scheme, event, syncEpoch }witheventdecoded by M2-23'sdecodeEvent; none givessignature_mismatch. Errors areWebhookVerificationErrorwithcodefrom exactly this list; messages never contain the body, a signature or a secret.verifyWebhookRequest(request, options): the same for a Fetch APIRequest, reading the body once as bytes. It servesinteractions_urlrequests as well (11 9).WebhookKeyCache: fetchesgetWebhookKeysonce, trusts every listed key (next,active,retiring), refreshes after the response'sCache-Controlmax-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 themax-ageof the response; new keys appear in it beforehand asnext").- Not in the SDK: storing seen
webhook-idvalues (webhooks.md 3 point 4 needs the app's own storage forwebhook.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).
-
Testing (
testing).FakeServer.start({ clock, ackTimeoutMs, tokenTtlMs, ... })listens on127.0.0.1port 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 withack, inflight window and redelivery withredelivered: trueafterackTimeoutMs, a fresh EdDSA token per delivery that replacesinvocation_jti(claims of 05 5, published atgetJwks),startRun,resumeRun,renewInvocationToken,awaitRunInput,finishRunwith the answers of 09 4.5 and 4.5.1,patchRunwith the path table andbase_version,createMessagewithclient_msg_id, the command, component, heartbeat and autocomplete operations of note 9,getWebhookKeysandsendWebhook(url, envelope, { hmacSecrets })signing with its own key. Every handler builds its body with the generated types, sotscholds the response shapes. Fault injection per operation and count:store_then_502,store_late,rate_limit(429withRetry-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 thewebhook-signaturevalue withv1aentries first in seed order and thenv1entries in the given order (D-M2-26-10). The FakeServer implementsScenarioServer(note 2), so the scenario functions run against it and, through thesdkserverclient of note 12, against the real server. -
Scenarios and live runs (Ruling for docs/DECISIONS.md).
test/scenarios.test.tsruns 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'ssdkserverunchanged.test/live/global-setup.ts(VitestglobalSetup) builds it once the waysdk/python/tests/realserver/conftest.pydoes,go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserverinservices/server, and hands the binary path to the test files (Vitestprovide, or the equivalent of the pinned version).test/live/sdkserver.tsruns it with-test.run '^TestSDKServer$'inside the test worker, because only the process that owns its stdin can drive it; it drivessdkserverover its JSON-lines protocol (start {config, agent_jkt, dir}with the sameconfigas M2-29's fixture,trigger,forge_token,run,restart,stop; answersSDKSERVER <json>) and implements the TypeScriptScenarioServeron it (passTimesleeps real time).test/live/scenarios.live.test.tsstarts it once inbeforeAlland stops it inafterAllwithstopand then by PID. The live files run insidenpm test, so the rootmake testandmake checkrun them. Without Docker or the Go tool chain they fail and never skip. Coverage (quality.coverage.typescript_min_percent) excludestest/live/.tools/ci/components.pygivessdk-typescriptthe prefixservices/server/, as M2-29 did forsdk-python(test intest_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 beforestartRun. -
typedoc gate (Ruling for docs/DECISIONS.md).
typedoc.json: entry pointssrc/index.ts,src/core.tsandsrc/testing/index.ts;validationwithnotExported,invalidLinkandnotDocumentedtrue;treatValidationWarningsAsErrors: true;requiredToBeDocumentedwith every reflection kind the hand layer exports; the generated core outside the doc rule like in M2-23 note 8 (marksrc/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. Scriptdocs:check=typedoc --emit none; scriptlint=eslint .thennpm run docs:check, so the rootmake lintfails on an undocumented export. Scriptdocswrites the HTML reference tobuild/reference/. Pintypedocexactly and add it todocs/quality/toolchain.mdand topinned_tools()oftest_toolchain_doc.py. -
Reference in the documentation site (Ruling for docs/DECISIONS.md). The recipe
docsrunsnpm --prefix sdk/typescript run docsbeforemkdocs buildwhensdk/typescript/package.jsonis tracked. The hooktools/docs/mkdocs_sdk_reference.pyadds every file ofsdk/typescript/build/reference/inon_filesas a generated static file underapi/typescript-reference/(File.generatedwithabs_src_path); with the package tracked and the directory missing or empty it raisesPluginErrornaming the npm command (fail closed, 23 1 point 3); without the package it does nothing. The English pagedocs/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 targettypescript-reference/index.html; MkDocs validates that link in the strict build because the hook makes the target part of the build.make linkcheckchecks tracked files on disk, where the target does not exist, so its lychee call excludes exactly the URL pattern ofdocs/api/typescript-reference/(check the form against--helpof the pinned lychee); a test asserts the pattern matches nothing else..github/workflows/docs.ymlgets theactions/setup-nodestep ofci.yml(same SHA and Node line) andnpm ci --prefix sdk/typescript --ignore-scripts --no-audit --no-fund --loglevel=errorbeforemake docs;make runners-checkstays green. -
Configuration defaults.
src/config.tsexportsDEFAULTSwith 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.tsreadsdocs/operations/configuration.mdand holds every value equal to the "Default" column (durations parsed to milliseconds). Every option can be overridden by the caller. -
Rulings for
docs/DECISIONS.mdsection 10, one row each with date, decision, reason and place:D-M2-30-1parity, ported scenario functions and shared data (note 2);D-M2-30-2entry points,jose,wsonly intesting, standard library for webhooks and DPoP,dist/(note 3);D-M2-30-3per-attempt DPoP, nonce retry outside the count, idempotency table and backoff reading, run binding ofInvocation-TokenthroughAsyncLocalStorage(note 4);D-M2-30-4close-code reactions and resume rule for agents (note 5);D-M2-30-5verification order, requiredinstanceIdandagentId, allow-list failure asinvalid_invocation_token(note 6);D-M2-30-6inbox, acknowledgement and start outcomes (note 7);D-M2-30-7patches over REST, local path check, one rebase (note 8);D-M2-30-8command reconciliation per declared scope with a store (note 9);D-M2-30-9webhook verifier API, error codes, trust-list refresh bymax-age, nowebhook-idstore, no Standard Webhooks dependency (note 10);D-M2-30-10FakeServer model and faults (note 11);D-M2-30-11live runs against M2-29's unchangedsdkserverover its stdin protocol insidenpm test, no Go test (note 12);D-M2-30-12typedoc gate with the generated core outside the doc rule (note 13);D-M2-30-13reference mount, linkcheck exclusion and docs workflow (note 14);D-M2-30-14defaults 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 ofdocs/api/webhook-test-vectors.jsonverifyWebhookaccepts the body with the header value of the case, itspublic_keyvalue(s) and itshmac_secrets, atnowequal 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, anotherwebhook-idand another timestamp each givesignature_mismatch;signWebhookreproduces 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 ofderived_hex(the string is built in memory and never written or printed), and everyhmac_secretsvalue of the signature cases parses withparseWebhookSecret.webhooks.test.ts:missing_headerfor each header;invalid_timestampforabc,1.5, empty; timestamps exactlywebhook.receiver_tolerancebefore and afternowaccepted, one second beyond rejected withtimestamp_out_of_tolerance;no_trusted_key; header with several spaces, an unknown schemev2,...and bad base64 still verifies by its valid entry; a key of another type and a wrong prefix throwWebhookConfigError; a body parsed and serialized again fails (raw-body rule); av1entry of another length does not throw;verifyWebhookRequestwith a FetchRequestcarrying a signedcommand.invokedenvelope (interactions_url) returns the decoded event andsyncEpoch; an envelope of unknown type returnsUnknownEvent;WebhookKeyCachefetches once, refreshes aftermax-age, never on a failed verification; a seeded set of random headers and bodies gives onlyWebhookVerificationError, never another exception; no error message contains the body or a secret.invocation.test.ts(09 4.5 points 1 to 10):algnone,HS256with the public key as secret andES256arerejectbefore any key lookup;typat+jwtisreject; an unknownkidreloads the JWKS once, a second unknownkidwithinadapter.jwks_refresh_min_intervaldoes not reload and givesunknown_kid; foreignissand foreignaudarerejectwithinvalid_invocation_token;exppassed by more thanadapter.invocation_token_leewayisexpired, by less isaccept;iatin the future beyond the leeway isreject; payload mismatch ofrun_id,thread_idor the triggering object isreject; the samejtitwice isreplayed; actual mode different orunknownisbilling_mode_mismatch;hostagainstscopedistool_class_mismatch,chatagainstscopedisaccept; a higherpermission_versionreloads the allow list and a missingsubisinvalid_invocation_token; no log line contains the token.azp6.test.ts(AZ-P6 for the SDK): anAgentLoopwith a runtime spy receivesagent.invocationevents from the FakeServer with a missing token, a wrong signature, a foreignaud, an expired token, a repeatedjti, a differentbilling_modeand a higher tool class: the spy is never called; the FakeServer recordsfinishRunwithinvalid_invocation_token,billing_mode_mismatchortool_class_mismatchwhere 09 4.5 says so, a renewal for the expired token, nothing for the repeatedjti; with a valid token the spy is called once, after200onstartRun.scenarios.test.ts: the scenario names oftestingin camelCase equal the list ofsdk/testdata/scenarios/README.md; every scenario function of note 2 against the FakeServer, at least AG-08 adapter side (no start; same tokens atrenewInvocationTokengive403 invocation_token_invalidwithreason: invalid), AG-09 (two loops with their own inboxes and connections: exactly onestartRunsucceeds), AG-12 (a rawpatchRunon/stategives400 validation_failedwitherrors[].codepatch_path_forbidden; an oldbase_versiongives409 base_version_conflictwithcurrent, after which the updater's next patch succeeds;RunUpdater.patchon/statethrowsPatchPathErrorwithout 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;startRunwith the expired token gives403 invocation_token_invalidwithreason: expired, with a replaced tokenreason: superseded; afterrestart()the redelivery's newer token replaces the stored one and the run starts exactly once).loop.test.ts: each start outcome of note 7 (replay200,supersededwaits,invalidfinishes,409 already_started,403 agent_invocation_denied, renewal at most once in flight); anunknown_kidevent is not acknowledged; restart resumes a not started inbox entry;FileInboxStoremodes0700and0600;run.cancel_requestedcalls the cancel callback; a request made inhandleRuncarries the run's token inInvocation-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 peragent.run_update_min_interval; one rebase on409 base_version_conflict; steps with UUIDv7 ids; follow-up states only forward;finishflushes first and a repeated end state is success.context.test.ts: every golden pair ofsdk/testdata/context/renders byte for byte, among them a message of an author withouttrigger, another agent, an import, a file content of the trigger's upload (marked data) and a transcript of the trigger's voice message (marktrigger);unverifiedtext appears only inside the marked block; withoutboundarytwo calls use different boundaries; a content containing a forged end marker does not close the block; the trigger text never appears insystem.gateway.test.ts: identify fields and the DPoP proof (htm,htu,nonceofhello,ath); first heartbeat offset from the seeded random; missingheartbeat_ackreconnects;resumewithinresume_window_ms,identifyafter it and after4006; one case per close code of note 5 (stop or reconnect, the4008wait); backoff bounds per attempt; unknown op ignored;nullmember dropped;PollingConsumerwith the same handler.client.test.ts:BearerandDPoPschemes; proof claims and 64-byteES256signature;401 use_dpop_nonceretried exactly once with the new nonce;IDEMPOTENT_EXEMPT_OPERATIONSequals the operations of openapi.yaml with reasonsagent_runtime,client_idandread_only; a plain POST without key is never retried;503 unavailableand429 rate_limitedwithRetry-Afterwait at least that long; backoff bounds;sdk.retry_max_attemptsrespected; timeout;KNOWN_PROBLEM_CODESequalsx-problem-codes; an unknown code is classified by status; one deprecation warning per operation; theInvocation-Tokenheader only insidebindRun, its name equal tocomponents.parameters.InvocationTokenof openapi.yaml, and two concurrent runs never see each other's token.outbox.test.ts(with the faultsstore_then_502andstore_late): the resend uses the sameclient_msg_idand gets the stored message;422 idempotency_key_reusedrops the entry;Outbox.writekeeps its key across retries.components.test.ts,commands.test.ts(reconcile creates, updates and removes; autocomplete guard sends nothing afterexpires_at; at mostcommand.autocomplete_max_choiceschoices),heartbeat.test.ts(unknowndetailsmember, a path inruntime.versionand a URL indegraded_reasonsrejected).fake-server.test.ts:rate_limitanddrop_connectionfaults; redelivery withredelivered: true;restart();requests()holds no header values.packaging.test.ts(afternpm run build): the three entries import;dist/core.jsimports nonode: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 undercomponents.schemasof openapi.yaml (ADR 0026 "Confirmation").- M2-23's
tolerance.test.tsandgenerated.test.tsstay 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"): copysdk/typescriptwithoutnode_modules,distandbuildinto a temporary directory with a link to the realnode_modules, appendexport const undocumentedExample = 1;to the copy'ssrc/index.ts, runnpm run lintthere: non-zero exit, output namesundocumentedExample; the unchanged copy passes; a symbol only insrc/generated/is not reported. The real working tree stays untouched (git status --porcelainbefore and after).tools/docs/tests/test_mkdocs_sdk_reference.py: the hook mounts every file of a temporary reference directory underapi/typescript-reference/; raisesPluginErrorwith 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 thelinkcheckrecipe matches the reference path and no other tracked link.tools/docs/tests/test_toolchain_doc.py: thetypedocrow matches the lock.tools/ci/tests/test_components.py:services/server/internal/runs/app/patch.goselectssdk-typescriptas well asserverandsdk-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/typescripthas 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) insidemake 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.jsonverifies,signWebhookreproduces each header byte for byte, changed bodies, IDs and timestamps fail, and the time window followswebhook.receiver_tolerance; the verifier servesinteractions_urlrequests. - 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 lintandmake lint(deliberate-export test); the reference is built inmake docsand linked fromdocs/api/sdk-typescript.md. -
npm run buildproducesdist/with the entries.,./coreand./testing;./coreuses 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 checkis 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
sdkandsdk/typescript(and ofsdk/testdata/scenariosandsdk/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.mdand the rootCHANGELOG.mdentries 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), with attention to the M2 "Red team" items "forging or reusing the invocation token" and "faking the reported mode" (nameazp6.test.ts,invocation.test.tsand the AG-28 scenario in the request), to requests of a run withoutInvocation-Token("confused deputy through the server API", 07 SR9) and to forged webhooks (T-PL-01); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- 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). - Follow-ups that could not be done in this scope, each with its reason; the pinned versions of
jose,wsandtypedoc; 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>.