11 Extensions: apps, webhooks, SDK¶
Status: draft M0. Normative for the module apps, for integrations and for the SDKs.
Terms: 00-glossary.md. Tables: 03-data-model.md
(apps, app_installations, webhook_subscriptions, webhook_deliveries, incoming_webhooks,
commands). Actions and scopes: 05-actions.md. Agent contract:
09-agents.md. API conventions, versioning, deprecation:
06-api.md. Webhook format in detail: ../api/webhooks.md.
Decisions: ADR 0022,
ADR 0026.
The additions requested during drafting are now in the core contract (section 15).
1. Principles¶
- Extensions never run in the server process (B6). They are separate processes and speak only the public API.
- Every extension is a principal with its own token, its own grants and its own limits. Apps also have scopes; both must match.
- The API that extensions use is the same API that the apps and the reference adapters use. There is no shortcut for "own" extensions.
- The server never loads a URL from user data (SSRF ban, Baseline section 4). Manifests are uploaded, not fetched from an address; outgoing webhooks go only to allowed hosts.
2. Agent or app¶
| Agent | App | |
|---|---|---|
| Purpose | Conversation partner with a runtime (model) | Integration of another system |
| Runs, live box, sessions | yes | no |
| Operator of a channel | yes | no |
| Triggered by | messages, commands, components from people | commands, components, subscribed events |
| Backend policy | yes (10-backend-policy.md) | no |
| Scopes | all except users:read and costs:reconcile, implicit (05 section 2.6) |
only granted ones |
| Manifest | no (configuration in agents) |
yes |
| Highest level | write |
write |
| Triggers agents | never | never |
Rule of thumb: if a model answers people, it is an agent. If it brings events of another system into the chat or actions from the chat into another system, it is an app.
3. App manifest¶
3.1 Format¶
The manifest is JSON according to the schema app_manifest.v1 (JSON Schema 2020-12, in the repo
next to the API specification). YAML is allowed as an authoring format; the console converts it to
JSON before uploading. Unknown fields are an error. Reason: a typo in a scope must not be silently
ignored.
| Field | Required | Content |
|---|---|---|
schema_version |
yes | 1 |
slug |
yes | ^[a-z0-9][a-z0-9-]*$, unique in the instance |
name, description, publisher |
yes | display |
version |
yes | SemVer of the manifest (apps.manifest_version) |
homepage_url |
no | display only; the server never loads it |
min_api_version |
yes | lowest API version that the app needs (06-api.md) |
scopes.required, scopes.optional |
yes | scopes from 05 section 2.6 |
events |
no | event types from 05 section 6 for which the matching scope is required |
delivery.mode |
if events is set |
gateway or webhook |
delivery.events_url |
with webhook |
HTTPS address for outgoing webhooks |
delivery.interactions_url |
if commands or components without a gateway | HTTPS address for synchronous requests; if set, commands, interactions and autocomplete requests of the app go there instead of the gateway (section 9) |
commands |
no | commands according to 09-agents.md section 10, without reserved names |
components |
no | component types used (button, select, form); only for the consent |
requested_grants |
no | per entry target_kind, level (read or write), capabilities (only upload, R-DM-20), purpose |
incoming_webhooks |
no | expected incoming webhooks with name and purpose |
data_egress |
yes, if events or interactions |
plain text: which data leave the instance and where to |
A manifest is at most app.manifest_max_bytes in size. The server stores it unchanged together
with its hash (apps.manifest_sha256).
3.2 Validation rules¶
- Every scope is in the catalogue; every event is in 05 section 6 and is not a
personal event of another principal (
access.changed,draft.*and so on can never be subscribed to). - For every event, the scope of its receive action is contained in
scopes(for examplemessage.createdneedsmessages:read). - All addresses are
https, without user information and fragment; at installation their canonical host and port match an entry ofwebhook.allowed_hosts(R-DM-17). Entries have the formhost[:port]; an entry without a port allows only the https default port, so an address on another port needs an entry with that port (DECISIONS.md D-M2-26-8, which replaces the comparison of the host without port). If it is missing, the server rejects the manifest with400 webhook_host_not_allowed; the operator adds the host to the process configuration (webhook.allowed_hostschanges only at start, docs/DECISIONS.md D-M2-24-5). requested_grantsnever containstrigger(R-DM-08) and at mostuploadas a capability (R-DM-20; check ingrants). Reason for excludingmanage: an app withmanagecould create grants itself (R-AZ-05) and read channel prompts; an integration never needs that.exportwould mean bulk extraction of histories.- Commands as in 09-agents.md section 10.2, at most
command.max_per_principal. Manifest commands are instance-wide:scope_kindabsent orinstance, noscope_id, no reserved name (09 section 10.1), descriptions withincommand.description_max_chars.validateManifestandinstallAppreject a violation with400 validation_failedand a pointer below/manifest/commands/<index>. Reason: a manifest is independent of the instance and names no target (4.1); narrower scopes come throughregisterCommandwithcommands:write.
3.3 Example¶
Neutral, shortened:
schema_version: 1
slug: repo-bridge
name: Repo Bridge
description: Brings events from a code hosting service into a forum.
publisher: Example Org
version: 1.2.0
homepage_url: https://bridge.example.com
min_api_version: "1.0"
scopes:
required: [messages:write, commands:write, components:write]
optional: [messages:read, events:subscribe]
events: [message.created]
delivery:
mode: webhook
events_url: https://bridge.example.com/hooks/events
interactions_url: https://bridge.example.com/hooks/interactions
commands:
- name: issue
description: Create an issue
options:
- {name: title, description: Title of the issue, type: string, required: true}
required_level: write
components: [button, form]
requested_grants:
- {target_kind: channel, level: write, capabilities: [], purpose: "Create posts per ticket"}
data_egress: "Messages of the granted channels go to bridge.example.com if messages:read is granted."
4. Installation, update, uninstallation¶
4.1 Installation¶
sequenceDiagram
autonumber
participant O as Owner (console)
participant S as Server
participant DB as PostgreSQL
O->>S: Upload manifest (file, no URL)
S->>S: Schema and validation rules (3.2), hash
S-->>O: Consent dialog: scopes, events and target hosts, commands, components, grants, data_egress
O->>O: choose optional scopes, choose grant targets, preview "Who has access"
O->>S: app.install (step-up)
S->>DB: BEGIN, lock permission_version
S->>DB: principals (kind app), apps, app_installations (granted_scopes)
S->>DB: commands, webhook_subscriptions, grants (per grant.create)
S->>DB: api_tokens (hash), event_log, audit, COMMIT
S-->>O: Token shown once, key ID for webhook signatures
- The consent dialog shows in plain text what the app may read and write and where it may send data. Reason: the owner decides on a data outflow; they must see it.
- Grants are created with concrete targets (
grant.createper target, with audit). A manifest can never set a target itself. - The commands of the manifest are created in the transaction of
app.install, before grants and tokens, as instance-wide commands owned by the app (scope_kind = instance), each withcommand.changed; the audit entry ofapp.installlists them incommands_added. A command of an app appears only where the app is reachable (section 9): in channels where the app haswrite, while its installation isactiveandcommands:writeis granted. Until the owner creates a grant, it appears in no channel. - The installation (
app.install) creates the principal,appsandapp_installationsand creates neither a token nor a grant: grants follow per target withgrant.create, tokens withtoken.create(createToken). The diagram above shows the whole consent flow, not one transaction (docs/DECISIONS.md D-M2-24-8). - The preview of a grant (
previewGrant, "Who has access") runs the apply step ofgrant.createand therefore needs an existing subject. The administration (desktop and console) previews the grants of the chosen targets right afterapp.installand creates them with a second confirmation; a step that fails stops the flow, shows what succeeded and offers to repeat the failed step or to uninstall the app (docs/decisions.d/M6-08.md D-M6-08-1). - The token appears exactly once. Rotation: create a new token, revoke the old one
(
token.create,token.revoke).
4.2 Update¶
A new manifest with the same slug shows a diff. New scopes and events need a new consent
(app.scopes.update, step-up). Until then the app runs with the old granted scopes; new commands
and events that need new scopes stay inactive. Removed scopes, events and commands take effect
immediately. An update never adds a grant; grants arise only with grant.create. New target hosts
of the delivery are confirmed by the update itself (owner, step-up, app.install); they must be in
webhook.allowed_hosts. A token keeps the scopes it was created with: a withdrawn scope takes
effect from the next request, a newly granted one needs a new token.
Commands follow the manifest. The update compares the old and the new commands by name without
regard to case: added commands and commands with a changed description, options,
required_level or control are written (version + 1 and command.changed only on a real
change); commands the new manifest drops get removed_at and command.changed. Commands that the
app registered through the API (registerCommand) and that neither manifest names stay. The audit
entry of the update lists commands_added, commands_changed and commands_removed; there is no
command.register or command.remove entry, because the app did not act. "Inactive" is decided
by the check of section 9, not stored in rows: a command of an app without commands:write is
hidden, and a withdrawn commands:write or components:write (app.scopes.update) takes effect
at once without changing a row. No command.changed is written when only the reachability of a
command changes (scopes, grants, suspension, resumption), because the row stays the same; clients
see the change at their next listChannelCommands, and an invocation answers 404.
4.3 Suspension and uninstallation¶
| Step | app.suspend |
app.uninstall |
|---|---|---|
| Tokens | blocked, not revoked | revoked |
| Deliveries | paused, position kept | subscriptions disabled |
| Commands | hidden (the check of section 9 finds no active installation; rows unchanged) |
removed (removed_at, command.changed) |
| Components of the app | disabled (the active components of its cards, one message.updated per card) |
disabled (likewise) |
| Grants, incoming webhooks | remain | revoked |
| Messages of the app | remain | remain (author is kept, principal disabled) |
app_installations.state = 'suspended' is set by the action app.suspend and reset by
app.resume (both owner, step-up, audit; 05). Reason: an emergency stop must not force a
reinstallation with a new consent. Incoming webhooks of a suspended app stay, but every post
answers 404 while the installation is not active; after app.resume the same URL works
again (8.4).
app.resume makes the commands visible again; the disabled components stay disabled, and the
app may reactivate them with updateComponent within the rules of component.update
(09-agents.md section 11). Reason: 4.3 does not bring them back, and a stale
button is worse than a new card. An invocation or interaction committed before the suspension
reaches the app on no way: the job runs.app_dispatch finds the installation not active and sets
an interaction failed (section 9), and the delivery check refuses the event
(app_not_installed). The audit entries of app.suspend and app.uninstall carry the number of
disabled components (components_disabled), that of app.uninstall also commands_removed.
5. Scopes and grants¶
An app may perform an action only if both hold (05 section 2.6):
- The grants of the app allow the action on the target (as for people, at most
write). - The installation has granted the scope of the action, and the token of the request carries it too (08-identity.md, section 12).
Which actions a scope allows is stated only in the table in 05-actions.md, section 2.6 (source:
app_scope per action in the action catalogue, ADR 0049). The scopes themselves are the enum
AppScope in openapi.yaml; the check api-catalogue-sync (M1-03) keeps both the same. Typical
use:
| Scope | Typical app |
|---|---|
messages:read |
mirroring replies into another system |
messages:write |
every posting app |
files:read, files:write |
attachments |
commands:write |
commands |
components:write |
buttons, forms |
users:read |
resolving names |
events:subscribe |
every subscribing app |
costs:reconcile |
cost reconciliation (10-backend-policy.md 12.4; ADR 0055) |
Reason for both: grants say "where", scopes say "what". An app with write on a channel
but without messages:read can post without reading along.
6. Isolation and limits¶
| Measure | Content |
|---|---|
| Own process | outside the server, any language, any location |
| Own token | only the hash is stored; optionally DPoP-bound (08-identity.md) |
| Rate limits | classes from 05 per principal; incoming webhooks per webhook (rl.webhook_in) |
| Delivery quota | at most webhook.max_pending_per_subscription open (pending) deliveries per subscription; the round whose insert reaches the quota sets an active subscription paused and audits webhook_out.transition with cause max_pending; rows keep being created while paused, so no event is lost; the owner learns it from the audit entry and the state in listWebhookSubscriptions (DECISIONS.md D-M2-26-2) |
| Timeouts | webhook.delivery_timeout per delivery, app.interaction_timeout per synchronous request |
| Suspension | app.suspend, app.uninstall; automatically disabled after webhook.max_consecutive_failures |
| Never allowed | triggering agents (origin = app), step-up actions (R-AZ-03), DM channels, channel prompts, manage, trigger |
Deliveries come from the event log, not from a copy: a paused subscription continues with its
pending deliveries where it stood. A gap is detected at two points (DECISIONS.md
D-M2-26-5): at the head of a subscription, when the event of its lowest pending delivery is no
longer in the log (sync.event_retention), and in the round of the worker consumer webhooks,
when retention removed events before the consumer read them (the lost range of the round). In both
cases the subscription becomes disabled with cause gap instead of skipping events silently, its
expired pending deliveries become dead with last_error = event_expired, and the audit entry of
webhook_out.transition names the lost range.
7. Outgoing webhooks¶
| Rule | Content |
|---|---|
| Subscription | webhook_subscriptions per installation; event_types is a subset of the granted events. Agents use the same table with agent_id as transport (09-agents.md section 4.2) |
| Format | body = event envelope as on the gateway (04 section 2.2, ADR 0052) |
| Signature | Standard Webhooks, headers webhook-id (= event ID), webhook-timestamp, webhook-signature with scheme v1a (Ed25519) |
| Key | server_signing_keys with purpose webhook_out and alg = 'EdDSA'; ES256 is not allowed for this purpose because v1a only knows Ed25519 |
| Order | strictly by seq per subscription; the next event only after the previous one succeeded |
| Check | before every attempt webhook_out.deliver with the current grants and scopes of the app (filter mode, no denial in the audit); an event the app may no longer receive ends dead with last_error = not_authorized, without a request and without counting as a failure |
| Success | only 2xx; 410 disables the subscription; 429, 502, 503 and 504 throttle and honour Retry-After; redirects are never followed, a 3xx is a failure (../api/webhooks.md, section 4) |
| Retry | backoff with jitter between webhook.retry_backoff_base and webhook.retry_backoff_cap (03) |
| Timeout | webhook.delivery_timeout; the response body is not stored |
| SSRF protection | only https, host in webhook.allowed_hosts, delivery through the egress client with an address check after DNS resolution (19-security.md, SEC-NET-1 to SEC-NET-5), connection bound to the checked address, no redirects |
Evidence for Standard Webhooks (https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md,
retrieved 2026-10-10, [verified]): the three headers; v1 symmetric with HMAC-SHA256, v1a
asymmetric with Ed25519; the signed content is msg_id.timestamp.payload; webhook-id serves the
receiver as an idempotency key and stays the same across retries; only 2xx is success,
410 means disable the endpoint, 429, 502 and 504 mean throttle; recommended timeout
between 15 and 30 seconds. ../api/webhooks.md defines the wire format in
detail.
Reason for the asymmetric signature: the receiver needs only the public key of the purpose
webhook_out, which the server publishes at GET /api/v1/instance/webhook-keys (getWebhookKeys);
the JWKS (getJwks) contains only keys for access tokens and invocation tokens. A leak at the
receiver does not allow forged webhooks.
8. Incoming webhooks¶
8.1 Creation and call¶
webhook_in.create(manageon the channel, step-up) creates an incoming webhook for a channel. It always writes as an app (incoming_webhooks.app_id) withwriteon the channel. An owner can, in the same confirmed action, create an "incoming app" with a built-in minimal manifest (onlymessages:write, grantwriteon exactly this channel); others choose an existing app, becauseapp.installis reserved for the owner. The API has no combined operation for this; the administration composes it frominstallAppwith the built-in manifest,createGrantandcreateIncomingWebhookunder one confirmation (docs/decisions.d/M6-08.md D-M6-08-4).- Call:
POSTto the webhook address withAuthorization: Bearer <token>. For senders without headers, the server allows the token as the last path segment; access logs redact this segment. An unknown webhook and a wrong token produce the same response404. - Two operations serve the call, with the same action
webhook_in.post, request body and answers (DECISIONS.md D-M2-27-1):postIncomingWebhookWithBeareronPOST /api/v1/hooks/{webhook_id}takes the token fromAuthorization: Bearer <token>, where a missing header or another scheme counts as a wrong token;postIncomingWebhookonPOST /api/v1/hooks/{webhook_id}/{token}takes the last path segment and ignoresAuthorization. Of the root security onlybearerAuthapplies to the Bearer operation, with the webhook token, which the token check of 8.4 verifies, not the authenticator (D-M2-27-17). - The token appears once; only the hash is stored. The token has the kind
whi(bothy_whi_...);createIncomingWebhookreturns it inside the absolute URLurlbuilt fromserver.public_urlwith the path ofpostIncomingWebhook; Bearer senders drop the last segment and send the token in the header.
8.2 Formats¶
format |
Body | Mapping |
|---|---|---|
native (default) |
content_md (Markdown, required, not only white space), optional title, thread_id, client_msg_id (UUIDv7) |
one message; client_msg_id makes retries idempotent: a repeat answers 200 with Idempotent-Replay: true and the original message_id and thread_id; the same client_msg_id with other content, or through another webhook of the same app, gives 422 idempotency_key_reuse (the hash context names the webhook, the body thread_id and the title). Without client_msg_id the server creates one |
alertmanager |
webhook payload of Prometheus Alertmanager | one message per notification: state, count, alertname, summary per alert from labels and annotations, links generatorURL and externalURL as text. The layout, the escaping of payload values, the URL rules and the shortening to message.max_content_bytes are fixed in DECISIONS.md D-M2-27-10 and D-M2-27-23. Retries are not deduplicated: every post gets a new server client_msg_id |
The format is decided per request from the body, not stored per webhook
(DECISIONS.md D-M2-27-5, D-M7-20-5). One request schema, IncomingWebhookPost,
holds the members of both formats. A body with at least one Alertmanager member (version,
groupKey, truncatedAlerts, status, receiver, groupLabels, commonLabels,
commonAnnotations, externalURL, alerts) is alertmanager; if it also holds a native
member, the answer is 400 validation_failed with the pointer of the first native member and
errors[].code format_mixed. Every other body is native. A format not listed in
webhook_in.formats_enabled gives 400 validation_failed with errors[].code
format_disabled. In a native body every other member gives 400 validation_failed
(unknown_member); in an Alertmanager payload members the payload description does not name, at
the top and in each alert, are ignored (routeLabels and notification_reason of v0.34.1, and
whatever a later version adds), because Alertmanager drops a notification answered with 400
and the payload is the sender's, not a request of a Bothy client. A native content_md above
message.max_content_bytes gives 413 payload_too_large.
Which formats are enabled is controlled by webhook_in.formats_enabled. Further formats do not
go into the server but come as an app (section 10). Reason: a built-in converter is
server code; only formats that are stable, widespread and mappable without state justify
that.
Evidence for Alertmanager (https://prometheus.io/docs/alerting/latest/configuration/, retrieved
2026-10-10, [verified]): webhook_config with url and http_config, whose block
authorization supports the type Bearer with credentials; the payload has version,
groupKey, truncatedAlerts, status, receiver, groupLabels, commonLabels,
commonAnnotations, externalURL and alerts with status, labels, annotations,
startsAt, endsAt, generatorURL, fingerprint.
8.3 Target thread¶
- Fixed thread of the webhook (
incoming_webhooks.thread_id), otherwise thread_idin the body, if the thread belongs to the channel, otherwisetitlecreates a new thread (required in forums), otherwise- main thread of the text channel.
Rules as built (DECISIONS.md D-M2-27-6 and D-M2-27-7):
- Rule 1 holds only while the fixed thread belongs to the webhook's channel; then
thread_idandtitleof the body are ignored. A fixed thread moved to another channel gives404, because a webhook writes only into its own channel. - Rule 2 uses the body
thread_idonly when that thread belongs to the webhook's channel; an unknown thread or one of another channel falls through to the next rule, so nothing about other channels shows. - Rule 3 in a forum creates a forum post with the message as its first message. In a text
channel the message is written into the main thread and the new thread starts from that
message; the answer names the new thread in
thread_id, and a replay answers the same thread. - Rules 2 and 3 apply to
nativebodies only. A forum reached by none of the rules gives400 validation_failedwitherrors[].coderequired_for_forum_post(pointer/titlefornative, empty foralertmanager).
One thread per alert group would need a mapping from an external key to a thread. Matching by title is ruled out because name-based identity is its own class of errors; the mapping comes as a separate table if needed (proposal in the report) or as an app.
8.4 Rules¶
flowchart TD
R["POST incoming webhook"] --> RL{"rl.webhook_in within limit?"}
RL -- no --> E429["429"]
RL -- yes --> PA{"rl.public_auth of the client IP free?"}
PA -- no --> E429
PA -- yes --> T{"Token valid, not revoked, installation active?"}
T -- no --> FT["failed token: one token of rl.public_auth"]
FT --> E404["404"]
T -- yes --> SZ{"Body up to webhook_in.max_body_bytes?"}
SZ -- no --> E413["413"]
SZ -- yes --> F{"Format enabled and valid?"}
F -- no --> E400["400"]
F -- yes --> TG{"Target thread (8.3)?"}
TG -- "fixed thread in another channel" --> E404
TG -- "forum without title" --> E400
TG -- yes --> AZ{"authorize app: webhook_in.post, message.create on the channel"}
AZ -- no --> E404
AZ -- yes --> W["Message origin app, mentions not resolved"]
W --> OK["201 with message_id and thread_id, 200 on a replay"]
The token check is a stage of the HTTP chain (02 9.1, StageWebhookToken) after the rate
limit rl.webhook_in and before the body is read, so a wrong token wins over a body that is too
large (DECISIONS.md D-M2-27-2 and D-M2-27-20). The authenticator never runs
on the two post operations; the request stays anonymous until the token check replaces it with
the webhook actor: the app with its active installation and only the scope messages:write
(D-M2-27-3). A failed check takes one token of rl.public_auth of the client IP; while that
bucket is empty the stage answers 429 without checking the token. Every refusal answers the
same 404 not_found as an unknown webhook: unknown ID, wrong or malformed token, revoked
webhook, an installation that is uninstalled or not active (so app.suspend stops posts and
app.resume lets the same URL work again, D-M2-27-13), a fixed thread in another channel and
every denial of webhook_in.post, message.create or thread.create (D-M2-27-4).
Both post operations are also served on the internal listener (server.internal_listen_addr)
in every role, for senders inside the stack such as Alertmanager, through a chain of their own
whose client IP is the peer address (no forwarded headers, R-SE-09) and whose rate limits count
per listener; nothing else of the API is mounted there (D-M2-27-15, D-M2-27-18).
origin = 'app'; the message never triggers agents (03,incoming_webhooks).- Mentions in the text are not resolved. Reason: a leaked token should not be able to trigger a flood of notifications to people; notification follows the channel settings.
- Content up to
message.max_content_bytes; no attachments through this path (files through the app API withfiles:write). - Revocation ends posting:
revokeIncomingWebhook,app.uninstall(4.3) and the archive of the channel, which revokes its active webhooks in the archive transaction with the archive time (DECISIONS.md D-M2-27-14, D-M2-27-21); after their commit no post writes.
9. Commands and interactions for apps¶
An app owns commands and components like an agent, but no agent runtime is involved: an app
command, an interaction on an app card, an autocomplete request for an app command and the reply
the server writes for an app never open a run, never carry an invocation token and never trigger
an agent (section 2, row "Triggers agents"; 05 section 5; R-AZ-18). command.invoked and
interaction.created are personal durable events of the module runs with the owning app as
audience, without invocation_token and without resource.version; the autocomplete request is
the ephemeral command.autocomplete_requested.
Apps receive them in one of two ways, fixed by the manifest:
| Way | When | Response |
|---|---|---|
| Gateway connection of the app | the manifest has no delivery.interactions_url |
like agents (09-agents.md 10.5, 11.4): respondToInteraction, answerAutocomplete or the op command.autocomplete_result, messages through createMessage |
interactions_url |
the manifest declares delivery.interactions_url |
synchronous POST request from the job runs.app_dispatch, signed like outgoing webhooks; response within app.interaction_timeout |
The way follows the manifest, not whether the app holds a connection at the moment, so every api
node chooses the same way without asking the hub. The durable event is stored on both ways and
reaches an app with events:subscribe also through its gateway connection, through listEvents
and through a webhook subscription that names its type (section 7), each time with the same id,
which equals webhook-id. Delivery is at least once (04 section 9); the app deduplicates by the
event id. On the gateway way the app needs events:subscribe to connect at all (04 section 6.4,
close code 4018). Open gap: sync.snapshot.read has no app scope in the catalogue of 05, so the
server refuses the snapshot of ready for every app and closes it with 4018 (decisions M1-64/11
and D-M2-25-24); until 05 gives the snapshot an app scope, an app on the gateway way receives its
events through listEvents or a webhook subscription. Wire format: ../api/webhooks.md, section 10.
Receive check. For a personal event to an app, the receive action agent.events.consume is
decided in filter mode by a rule for apps: the app must be the audience of the event (otherwise
not_self), its installation active (otherwise app_not_installed) and events:subscribe
granted (otherwise scope_missing). Agents keep their rule. In request mode, on the consumer
operations of agents (pollAgentEvents, ackAgentEvents), an app still gets 403 forbidden with
reason subject_kind_not_allowed.
Check of the person (the app side). When a person runs an app command or operates a
component on an app card, authorize checks the person and, as part of the visibility, whether
the owning app is reachable. It is reachable when its installation is active, the scope of the
feature is granted (commands:write for commands, components:write for components) and the app
holds at least write on the channel (09 section 10.3); the facts are those of the owning app,
not those of the person. The order: visibility of the place for the person; for commands the
reachability of the app; required_level of the command or trigger for the component; the
read-only and archive rules; for components the state, the expiry and the reachability
(component_inactive), then required_capability (capability_missing) and restricted_to
(component_restricted; an app card has no run, so run_starter lets only owners pass). Never
checked for apps: agent.invoke with its rules (origin, operator or mention, I2
billing_mode_personal_not_holder, SR2, SR3, project readiness, active runs).
| Case | Answer |
|---|---|
| App command whose app is not reachable | 404 not_found (no_grant), the same as an unknown command (R-AZ-01); missing from listChannelCommands |
getCommand of an instance-wide app command |
no channel is resolved, so only an active installation and commands:write count |
| Component on a card of an app that is not reachable | 409 component_used (component_inactive) |
| The owning app itself | reads its own commands without the reachability condition |
Only required_level limits who runs an app command, so agents and apps (at most write) can
run app commands whose level is write (spec gap). Denials of the person's request are audited
as for every action (05 section 2.4); the re-check of the job writes none.
App commands. The target thread is the given thread_id, otherwise the main thread of a text
channel, otherwise none (forum); there is no auto_thread, which exists only to give the run of
an agent a thread. One unit of work writes command.invoked and inserts the job
runs.app_dispatch, nothing else: no runs row, no token, no trigger_state; control is passed
through and changes nothing. The answer is 202 with CommandInvocation, accepted: true and no
run_id; a replay with the same Idempotency-Key writes no second event and no second job.
App cards. An app posts a card with createMessage (kind = card, components) like an
agent; this needs write on the channel and messages:write, not components:write. Without
components:write its components are inactive (above), and updateComponent and
respondToInteraction answer 403 scope_missing. A run_id from an app gives
400 validation_failed with errors[].code run_mismatch on /run_id, because an app has no
runs. Author, origin = app and the component fields come from the server; text that imitates a
system header stays content. The components list of the manifest serves only the consent and is
not enforced. An interaction on an app card answers 201 with an Interaction in state
received and without run_id; the single-use rule and message.updated work as for agents
(09 sections 11.3 and 11.4).
Job runs.app_dispatch. A request to an app is a consequence of a transaction, so it never
runs inside the person's request (02 section 8.5). The job (queue default, 02 section 12.3) is
inserted in the same transaction for every app command and every interaction on an app card,
whatever the way, and pauses in maintenance mode. In this order:
- The way. Gateway: the job ends, and an interaction stays
receiveduntil the app callsrespondToInteraction. Installation notactive: an interaction becomesfailed. - A re-check with the rights of this moment in filter mode:
command.invoke, orinteraction.createat the stagedeliver, which skips the state of the component. A person is the principal of a check without a request (05 section 5); an agent or app that ran an app command is checked as itself. Denied: no request, and an interaction becomesfailed. - An interaction moves from
receivedtodispatched. A state thatrespondToInteractionset first (acknowledged,failed,rejected) ends the job without a request. - The request to
interactions_url, outside every transaction. - The outcome: an answer that was used makes an interaction
acknowledged,202keeps itdispatcheduntil the app callsrespondToInteraction, every other outcome makes itfailed. A command stores nothing; the outcome is logged.
Interaction states change only by compare and swap; whichever of the job and
respondToInteraction leaves received or dispatched first wins. A fault of the server before
the request (for example the database) is retried with the default attempts of the job system;
once the request has been sent, the job always ends successfully, so a request is never repeated
automatically (09 section 1 point 5). Should the job still run again, for example after a crash
during the request, the request carries the same webhook-id, which the app uses as its
idempotency key. No event and no field tells the person the outcome after the commit; a display
such as "App is not responding" in the clients is open (spec gap).
Request to interactions_url.
| Part | Rule |
|---|---|
| Target | the delivery.interactions_url of the current manifest; before every request the server checks it against the current webhook.allowed_hosts (only https, no user information, canonical host and port match an entry, 3.2 rule 3) and sends nothing on a miss, because an egress.allowed_hosts entry would otherwise admit a host removed from webhook.allowed_hosts; then the egress client with an address check after DNS resolution and a connection bound to the checked address (19-security.md, SEC-NET-1 to SEC-NET-5) |
| Body | the envelope of the stored event exactly as a webhook delivery renders it (ADR 0052), with sync_epoch, without invocation_token and redelivered; for autocomplete the envelope of the ephemeral event |
| Signature | webhook-id (= event id), webhook-timestamp, webhook-signature with scheme v1a only, signed with the active webhook_out key (both keys during a key change); no v1, because HMAC belongs to a webhook subscription and an interaction address is none; no token, no secret, no Authorization |
| Redirects | never followed; a 3xx is no answer |
| Deadline | app.interaction_timeout per request |
| Size | the response body at most http.max_json_body_bytes, the bound of a createMessage body |
| Log | one line per request with outcome and reason; never the URL path, a header value, a body, an option or form value, a reply text or a signature |
Response forms with interactions_url:
| Response within the deadline | Effect |
|---|---|
200 to command.invoked or interaction.created |
the body is decoded strictly as MessageCreate (unknown members, duplicate keys, invalid UTF-8, JSON null or a missing client_msg_id make it invalid); the server writes it in the app's name into the target thread of the command or the thread of the card, with origin = app; message.create is decided as for every message of the app (write, messages:write), a card follows the card rules above, and client_msg_id makes a repeated job idempotent (04 section 5.1). A command without target thread (forum without thread_id) gets no message: the app answers 202 and posts with createMessage itself |
200 to command.autocomplete_requested |
AutocompleteAnswer with the rules of 09 section 10.5 (at most command.autocomplete_max_choices, non-empty name and value); an invalid answer counts as none |
202 |
accepted, the body is ignored; the app answers later through the API (respondToInteraction, createMessage, answerAutocomplete) |
| anything else | other status (including other 2xx), 3xx, deadline passed, body above the bound, invalid body, a reply the create path refuses, a blocked address or a transport error: no answer; an interaction becomes failed, and its component stays used (same reason as 09-agents.md 11.4) |
Autocomplete. An autocomplete request holds no transaction, so it runs inside the person's
request. With interactions_url the request ends at the earlier of the end of the person's wait
(command.autocomplete_timeout) and app.interaction_timeout: valid choices are the answer;
202 keeps the person waiting for answerAutocomplete until command.autocomplete_timeout; every
other outcome gives an empty list at once. On the gateway way command.autocomplete_requested goes
to one live connection of the app, which answers with the op command.autocomplete_result or
answerAutocomplete (command.autocomplete.respond, commands:write). An installation that
stops being active after the check gives an empty list at once (before the check the command
is hidden and answers 404). The person never waits longer than
command.autocomplete_timeout (R-AG-14).
10. Ready-made integrations as examples¶
All four integrations from requirement 19 are built and tested, not only described
(PLAN, "Block reference integrations" in M2). Code hosting and the ticket system are
reference apps in the folder plugins/ of the repo (plugins/github-app, plugins/jira-app; see
plugins/README.md). Monitoring alerts and cron reports need no
app of their own. The apps show patterns; they are not part of the server and use only the public
API.
| Integration | Way | Ticket |
|---|---|---|
| Code hosting (GitHub) | reference app plugins/github-app (TypeScript SDK) |
M2-49 |
| Ticket system (Jira) | reference app plugins/jira-app (Python SDK) |
M2-50 |
| Monitoring alerts | incoming webhook in the format alertmanager |
M2-27, M7-20 |
| Cron reports | incoming webhook in the format native or a report from an agent |
M2-27 |
Tests never call a real service: the counterparts are fake APIs with webhook fixtures from the
public documentation of the provider. The apps have their own VERSION and CHANGELOG.md in their
folder, but are not a release component from 18-release.md: instance operators
build them from the repository. Reason: they are patterns for your own apps, not a maintained
product with commitments.
10.1 Code hosting (GitHub)¶
Built as plugins/github-app (M2-49, plugins/github-app/README.md).
| Item | Content |
|---|---|
| Kind | app, own process |
| Input | GitHub webhooks to the app's own address, not to an incoming webhook of the server. Reason: the app must check the signature X-Hub-Signature-256 (HMAC-SHA256, value starts with sha256=, comparison in constant time) and keep a mapping from issue to thread (https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries, retrieved 2026-10-10, [verified]). The header is required and has the format sha256= plus 64 lower-case hex digits; the app checks it over the raw body before it parses anything and answers 401 without it or on a mismatch. Duplicates are recognised by X-GitHub-Delivery within github_app.dedupe_window. Events: ping; issues with opened, closed, reopened; issue_comment with created; pull_request with opened, closed, reopened; pull_request_review with submitted; every other event or action is answered 204 and ignored. |
| Output | forum channel, one thread per issue or pull request (mapping in the state of the app), messages per event. The key is <owner>/<name>#<number> in lower case (issues and pull requests share the numbers); titles #<n> <title> and PR #<n> <title>; a thread out of reach (404) is replaced by a new forum post once. |
| Commands | /issue with the option title and required_level trigger (the form is submitted with interaction.create, which needs trigger); the app answers 202, opens its own forum post with a form, creates the issue through the GitHub REST API when the form is submitted and answers in that thread; nothing about the person (name, handle, ID) goes to GitHub |
| Delivery to the app | commands and interactions through delivery.interactions_url (section 9); a webhook subscription to delivery.events_url only for mirroring |
| Scopes | messages:write, commands:write, components:write; optionally messages:read and events:subscribe with the event message.created if replies are mirrored back (05 section 2.6 row "Event subscriptions" requires events:subscribe for every subscribed event) |
| Grants | write on the forum |
| Loops | the app never mirrors back its own messages (author = app); only messages with origin = human and kind = text in a linked thread are mirrored; the issues opened event of an issue the app created and the issue_comment created event of a comment the app created are not posted |
| Foreign text | GitHub text never becomes a mention or a link in the chat: logins in code spans, bodies in fenced code blocks whose delimiter is longer than any backtick run of the text (03 content format rule 2); a link only to the event's https address; messages and titles cut to the limits of getInstance; titles without control and bidi characters (SEC-IN-4) |
| State | a state directory of mode 0700 with the thread map, drafts, created objects and seen identifiers, a lock file against a second process, and a queue of jobs worked in creation order; a write to GitHub is attempted at most once, a write to the server carries its stored client_msg_id or Idempotency-Key |
10.2 Ticket system (Jira)¶
Like 10.1. Evidence (https://developer.atlassian.com/cloud/jira/platform/webhooks/, retrieved
2026-10-10, [verified]): webhooks through the administration interface or REST; with a secret set,
Jira signs with HMAC in the header X-Hub-Signature in the format method=signature, currently
SHA-256, and announces that it may change the method in the future; events include
jira:issue_created, jira:issue_updated, comment_created. Consequence [derived]: the app reads
the method from the header and rejects unknown methods instead of assuming SHA-256.
Built as plugins/jira-app (M2-50, plugins/jira-app/README.md),
on the Python SDK. Where this table names no rule of its own, the row of 10.1 holds for Jira as
well.
| Item | Content |
|---|---|
| Kind | app, own process (Python SDK) |
| Input | Jira webhooks to the app's own address POST /jira/webhook, not to an incoming webhook of the server. The header X-Hub-Signature must occur exactly once; the method is the part before its first = and is compared exactly with an allow list that holds only sha256; every other method (sha1, sha512, SHA256, ...) is answered 401 before any HMAC is computed, also when its value would verify. The signature must be 64 hexadecimal digits [assumed: hex encoding as GitHub's]; the app computes HMAC-SHA256 with the webhook secret over the raw body and compares the 32-byte digests in constant time before it parses anything. Duplicates are recognized by the SHA-256 of the body within jira_app.dedupe_window (the signature carries no timestamp). Events: jira:issue_created, jira:issue_updated, comment_created; every other event, a project outside jira_app.projects and an update without changelog items are answered 204 and ignored. The payload members the app reads [assumed]: webhookEvent, issue.id, issue.key, issue.fields.summary, issue.fields.project.key, issue.fields.description, user.displayName, changelog.items[].field, fromString, toString, comment.id, comment.body, comment.author.displayName; rich text as a string or an ADF document. |
| Output | forum channel of the issue's project, one thread per issue (mapping in the state of the app). The key is Jira's issue.id, not the issue key, which changes when an issue moves to another project; title <KEY> <summary>, never retitled; a thread out of reach (404) is replaced by a new forum post once; jira:issue_updated posts only its field changes, one line each (description only as "changed") |
| Commands | /issue with the option title and required_level trigger; the app answers 202, opens its own forum post with a form (a project choice only when several projects share the forum), creates the issue through the Jira REST API with the issue type configured for the project when the form is submitted and answers in that thread; nothing about the person (name, handle, ID) goes to Jira; a refused create gives a new form card, an unknown outcome never a second issue |
| Delivery to the app | commands and interactions through delivery.interactions_url (section 9); a webhook subscription to delivery.events_url only for mirroring; both verified with the SDK's webhooks module and the keys of getWebhookKeys |
| Target API | Jira Cloud REST version 3: POST /rest/api/3/issue (https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post) and POST /rest/api/3/issue/<id>/comment (https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/#api-rest-api-3-issue-issueidorkey-comment-post) with Atlassian Document Format (ADF) bodies (https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/), basic authentication with the account e-mail and an API token (https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/); each [assumed], not retrieved on 2026-10-25: the build that wrote this row has no network, so the pages are named as the sources to check, and the app's tests run only against a Fake-Jira built after them (M2-50 report, "What is not proven"); redirects are not followed |
| Scopes, grants, loops | as 10.1; additionally the comment_created and jira:issue_updated echoes of a comment the app created add nothing |
| Foreign text | Jira text never becomes a mention or a link in the chat: display names, field names and values in code spans, descriptions and comments in fenced code blocks (03 content format rule 2); a link only to <jira_app.jira_base_url>/browse/<KEY> for a key of the issue key pattern, never to a URL of the payload; messages and titles cut to the limits of getInstance; titles without control and bidi characters (SEC-IN-4). Text sent to Jira is ADF text, never parsed as markup |
| State | SQLite in a state directory of mode 0700 with the thread map, drafts, created objects, seen identifiers and the job queue; a lock held with flock against a second process; one worker in creation order; a write to Jira is attempted at most once, a write to the server carries its stored client_msg_id or Idempotency-Key |
10.3 Monitoring alerts¶
Incoming webhook with the format alertmanager (section 8) in a text channel or a fixed thread.
Alertmanager sends the token as Bearer through http_config.authorization (evidence 8.2). No
app of its own is needed. The alert path of the instance itself uses exactly this path (PLAN M7-20). One thread per alert group is an app task (8.3).
10.4 Cron reports¶
Two ways:
- An agent reports by itself without a trigger (09-agents.md section 14).
- An external cron job sends
POSTin the formatnativeto an incoming webhook, withclient_msg_idper run of the job, so that retries create no duplicates. An integration test in M2-27 proves this: a retry of the same run produces no second message.
11. SDK design¶
Decision: ADR 0026. Two SDKs, Python and TypeScript, each made of two layers.
11.1 Generated core¶
| Language | Generator (Baseline R-Clients) | Content |
|---|---|---|
| Python | openapi-python-client | models and calls of all REST operations |
| TypeScript | openapi-typescript | types of all operations; runtime client openapi-fetch |
The core is generated from docs/api/openapi.yaml and compiled in the gate (23-quality.md).
It is not changed by hand. Gateway messages take their types from the schemas of
docs/api/openapi.yaml that docs/api/asyncapi.yaml references; no generator runs for AsyncAPI
(06-api.md 14 point 3).
11.2 Hand-written layer¶
| Module | Task |
|---|---|
auth |
token, optionally DPoP proofs with a key from a file or a key store, nonce handling |
gateway |
connection according to 04: hello, identify, resume, heartbeat with random offset, ack window, reconnect with full jitter, reaction per close code (on 4015 stop, do not reconnect) |
invocation |
check of the invocation token according to 09-agents.md 4.5: JWKS cache with throttled reloading, allow list EdDSA, typ, iss, aud, time with tolerance, jti store, callback for the reported mode |
runs |
run helpers: run.start, fetch the context, collect and throttle patches (agent.run_update_min_interval), track base_version, start over on 409, steps, follow-up states, run.finish with usage, cancel callback; sets Invocation-Token on every request during a run (07 SR9); 403 invocation_token_invalid with reason: run_inactive ends the work on the run |
components |
building blocks for button, select, form with the limits from 09; ready-made approval card (09 section 11.5) and question |
commands |
declarative command list with sync on connect; autocomplete with a time guard |
outbox |
sending with client_msg_id, which is stored before sending (exchangeable store); Idempotency-Key for other writes |
retry |
retry only for idempotent operations: network errors, 5xx, 429 with Retry-After; backoff with full jitter between sdk.retry_backoff_base and sdk.retry_backoff_cap, at most sdk.retry_max_attempts attempts |
heartbeat |
building block for agent_heartbeat.v1; rejects fields that could carry secrets |
webhooks |
check of Standard Webhooks signatures (v1a) for apps, also for interactions_url; both SDKs verify v1a (Ed25519 with the trust list of getWebhookKeys) and v1 (HMAC-SHA256 with a whsec_ secret, constant-time comparison) over the raw body inside webhook.receiver_tolerance: TypeScript (M2-30) and Python bothy_sdk.webhooks (M2-50) |
testing |
in-process FakeServer with fault injection, fake agent for the conformance suite, scenario helpers, recorded fixtures |
adapter |
acceptance and run loop of an agent adapter (09 4.3, 4.9): token check, acknowledgement only after the durable inbox, start from the inbox at most once with the newest token, renewal of an expired token, resume, cancel, the end of a run; the runtime starts only after 200 on run.start (Python, M2-29) |
inbox |
durable inbox of accepted events with their tokens and the jti store; a redelivery replaces the stored token by a newer one (09 4.3, 04 15.2; Python, M2-29) |
context |
composes the run context with trust marks: a fixed system part with author and trust per item, unverified content and every file text only inside marked data blocks (09 4.6, ADR 0054; Python, M2-29) |
The Python hand layer adds infrastructure modules that every module above uses: settings (the
named values of 16 and the SDK checks of server limits), clock (time source), ids (UUIDv7),
auth, errors, client (REST client on the generated core, SR9 binding), sync (the
synchronous hull) and sources (gateway and REST polling as event sources). The Python
webhooks module was added with the Jira app, its first Python consumer (M2-50).
The TypeScript hand layer (M2-30) has the same modules with the camelCase names of the Python
functions and the Python class names (Adapter, RunPatcher, Gateway, GatewaySource,
PollingSource, InvocationVerifier); inbox and adapter live in invocation and runs.
It has three entry points: . (Node.js only, everything except testing), ./core (the
browser-safe subset without secrets: generated types, tolerance layer, errors, settings,
components, renderContext, uuidv7, pages) and ./testing (FakeServer, signWebhook, the
scenario functions and doubles).
Errors are typed according to the problem format from 06-api.md (RFC 9457); a 404
means "not there or not visible" and is never treated as "definitely missing".
11.3 Runtimes and dependencies¶
- Python: asynchronous API first (adapters such as Hermes are asynchronous), plus a synchronous wrapper for scripts. Supported are the CPython versions that python.org still maintains. Keep dependencies small and choose them to be compatible with the Hermes environment (check in the fake Hermes test).
- TypeScript: ESM, active LTS lines of Node.js; browser only for the parts without secrets (no agent tokens in the browser).
- JOSE validation with a library that supports EdDSA and an algorithm allow list. Verified
candidates (retrieved 2026-10-10): PyJWT lists EdDSA with Ed25519 and Ed448
(https://pyjwt.readthedocs.io/en/stable/algorithms.html) [verified];
josefor JavaScript lists RFC 8037 andcreateRemoteJWKSet, license MIT (https://github.com/panva/jose) [verified]. The final choice is made by the build ticket with the license gate. - Python SDK (M2-29): PyJWT with
cryptographyfor the invocation token (EdDSA, algorithm allow list, header,iss,audand times compared by the SDK itself) and for the DPoP proofs (ES256);websocketsfor the gateway;httpxfor REST (the generated core);aiohttponly in the optional extratesting(the FakeServer). The licenses passmake licenseswithout a change to the allowlist. - TypeScript SDK (M2-30):
josefor the invocation token (EdDSA,compactVerifywith the algorithm allow list; header,iss,audand times compared by the SDK itself);openapi-fetchfor REST (the generated core); webhook verification and DPoP proofs with the Node.js standard library (node:crypto); the globalfetchandWebSocketof Node.js;wsonly intesting(the FakeServer needs a WebSocket server). The licenses passmake licenseswithout a change to the allowlist.
12. Versioning and compatibility¶
| Subject | Version | Rule |
|---|---|---|
| REST API | major version in the path, minor version additive | 06-api.md, ADR 0014 |
| Gateway protocol | protocol_version in identify and hello |
04 section 6.1 |
| SDK | SemVer per language, own changelog | new major version on a new API major version or a breaking change of the hand-written layer; minor version for new operations from the spec; bug fix as a patch |
| App manifest | schema_version, version |
new schema_version only on a breaking change; the server reads all supported ones |
The compatibility matrix is in sdk/README.md (repo root) with the columns
SDK version, API version, gateway protocol, minimum server version, status. A test in the gate
checks that every published SDK version passes its contract tests against the server versions of
its row.
Deprecation follows 06-api.md. The SDK writes a warning once per operation when
the server sends the headers Deprecation (RFC 9745) or Sunset (RFC 8594)
(https://www.rfc-editor.org/rfc/rfc9745.html, retrieved 2026-10-10, [verified]: RFC 9745 defines
Deprecation and refers to RFC 8594 for Sunset). Deprecated operations are marked as deprecated
in the generated core.
13. Guide: connecting your own agent in ten steps¶
Outline for the developer guide of the docs site. The guide is
docs/guides/connect-agent.md; it runs as a test against a fresh
server (services/server/test/guide, M2-37), which fails when a step differs from the text.
- Create the agent. The owner creates the agent (administration in the app or console or
operation
createAgent; handle, name,adapter_kind, backend policy). - Create a token. Have the agent token shown once, bound to an adapter key (DPoP), required
while
agentis inauth.dpop_required_for(default, 08-identity.md section 12). - Grants and operator.
write(and+upload) on the targets; set the agent as operator of a channel or space; choose the channel switches. - Install the SDK and connect. Gateway with
consumer; the SDK handles reconnecting andack. - Report adapter features. Heartbeat with
capabilities, reported mode and evidence source. - Register commands. Control commands and model commands, optionally autocomplete.
- Receive the invocation. Check the invocation token (SDK), compare the reported mode, fetch the context, respect the trust marks.
- Manage the run.
run.start, fill the live box with status, steps, text and metrics; send the answer;run.finishwith usage. - Become interactive. Follow-ups, stop, questions, approval cards, files.
- Test and operate. FakeServer scenarios from 09-agents.md section 16, logs without content and secrets, behavior on restart, version pinning.
14. Decisions of this chapter¶
| No. | Decision | Reason |
|---|---|---|
| R-EX-01 | Manifests are uploaded, never fetched by URL. | SSRF ban. |
| R-EX-02 | Unknown manifest fields are an error. | Typos in scopes must not take effect silently. |
| R-EX-03 | Apps never get manage and never trigger. |
No granting of permissions by integrations; no triggering of agents. |
| R-EX-04 | The consent shows target hosts and data outflow in plain text. | The owner decides on data that leave the instance. |
| R-EX-05 | app.suspend as an emergency stop without losing the granted scopes. |
Suspending must not force a reinstallation. |
| R-EX-06 | Outgoing webhooks according to Standard Webhooks v1a (Ed25519), strictly ordered per subscription. |
The receiver checks with a public key; order as in the gateway. |
| R-EX-07 | Delivery from the event log with a position per subscription; a gap leads to disabled. |
No silent loss, no second copy. |
| R-EX-08 | Built-in incoming formats are only native and alertmanager; everything else as an app. |
Little server code for foreign formats. |
| R-EX-09 | Incoming webhooks do not resolve mentions; the token is also allowed in the path, but redacted. | No notification abuse; senders without headers. |
| R-EX-10 | SDK = generated core plus hand-written layer; the core is never changed by hand. | The spec stays the source of truth; the hand-written layer carries the hard parts once. |
| R-EX-11 | The integrations from requirement 19 are build tickets with tests, not only examples: GitHub app on the TypeScript SDK, Jira app on the Python SDK, alerts and cron through the incoming webhook. | The requirement asks for them explicitly; one real app per SDK proves that the SDK is enough for apps, not only for agents. |
15. Alignment with the core contract¶
The additions requested during drafting are decided (integration of 2026-10-10):
| No. | Addition | Decision, location |
|---|---|---|
| E-EX-01 | actions app.suspend, app.resume |
adopted, 05 section 4.11 |
| E-EX-02 | no manage for apps |
adopted in a stricter form: agents and apps get only upload as a capability (03 grants, R-DM-20) |
| E-EX-03 | scope costs:reconcile |
adopted, 05 section 2.6 |
| E-EX-04 | webhook_out key only EdDSA |
adopted, 03 server_signing_keys |
| E-EX-05 | personal events for apps, app commands without agent.invoke |
adopted, 05 sections 4.10 and 6, R-AZ-18 |
16. Configuration values of this chapter¶
| Name | Place | Default | Meaning |
|---|---|---|---|
app.manifest_max_bytes |
Server | 65536 |
size of a manifest |
app.interaction_timeout |
Server | 3s |
deadline of one synchronous request to interactions_url (section 9); an autocomplete request ends earlier when command.autocomplete_timeout ends first |
webhook.delivery_timeout |
Server | 15s |
timeout of a delivery |
webhook.max_pending_per_subscription |
Server | 10000 |
open deliveries per subscription, above that paused |
webhook.retry_max_age |
Server | 72h |
after that a delivery becomes dead (webhooks.md, section 4) |
webhook.secret_rotation_overlap |
Server | 24h |
how long the server signs with the old and the new HMAC generation |
webhook.hmac_master_key_ref |
Server | file:<secrets.dir>/webhook_hmac_master_key |
reference to the master key from which the HMAC secrets of the subscriptions are derived (webhooks.md, section 2.1) |
webhook.receiver_tolerance |
Receiver | 5m |
recommendation, not a server value: tolerance for webhook-timestamp at the receiver and default of the check function of the SDKs |
webhook_in.max_body_bytes |
Server | 1048576 |
size of an incoming body |
webhook_in.formats_enabled |
Server | ["native", "alertmanager"] |
enabled incoming formats; the start check webhook_in_formats allows only native and alertmanager, each at most once; an empty list switches every format off |
sdk.http_timeout |
SDK | 30s |
timeout of a REST request |
sdk.retry_max_attempts |
SDK | 5 |
attempts per idempotent operation |
sdk.retry_backoff_base |
SDK | 500ms |
base of the backoff |
sdk.retry_backoff_cap |
SDK | 30s |
largest interval |
sdk.poll_interval |
SDK | 2s |
interval between calls of pollAgentEvents when the last page was empty; far below ratelimit.rl.agent_runtime.per_minute (M2-29) |
github_app.listen |
GitHub app | no default, required | host:port of the HTTP listener of the reference app of section 10.1; plain HTTP behind the TLS proxy of the instance |
github_app.server_url |
GitHub app | no default, required | base address of the instance; https, http only to a loopback address |
github_app.token_file |
GitHub app | no default, required | file with the app's API token (createToken), mode 0400 or 0600, read once at start |
github_app.dpop_key_file |
GitHub app | empty | key file of a DPoP-bound app token (auth.dpop_required_for), mode 0400 or 0600 |
github_app.webhook_secret_file |
GitHub app | no default, required | file with the secret of the GitHub webhook, mode 0400 or 0600, read at each delivery |
github_app.github_token_file |
GitHub app | no default, required | file with the token for the GitHub REST API, mode 0400 or 0600, read at each call |
github_app.github_api_url |
GitHub app | https://api.github.com |
base address of the GitHub REST API; https, http only to a loopback address |
github_app.repositories |
GitHub app | no default, required | list of {"repository": "<owner>/<name>", "channel_id": "<forum>"}, each repository once (compared without case); several repositories may share one forum |
github_app.state_dir |
GitHub app | no default, required | state directory of the app (mode 0700): thread map, drafts, created objects, seen identifiers, lock and job queue |
github_app.max_body_bytes |
GitHub app | 1048576 |
largest request body on each endpoint of the app |
github_app.dedupe_window |
GitHub app | 72h |
how long delivery identifiers, webhook-id values, the GitHub objects the app created and open drafts are kept; keep it at least webhook.retry_max_age |
github_app.max_queued_jobs |
GitHub app | 10000 |
open jobs of the app, above which its endpoints answer 503 |
jira_app.listen |
Jira app | no default, required | host:port of the HTTP listener of the reference app of section 10.2; plain HTTP behind the TLS proxy of the instance |
jira_app.server_url |
Jira app | no default, required | base address of the instance; https, http only to a loopback address |
jira_app.token_file |
Jira app | no default, required | file with the app's API token (createToken), mode 0400 or 0600, read once at start |
jira_app.dpop_key_file |
Jira app | empty | key file of a DPoP-bound app token (auth.dpop_required_for), mode 0400 or 0600 |
jira_app.webhook_secret_file |
Jira app | no default, required | file with the secret of the Jira webhook, mode 0400 or 0600, read at each delivery |
jira_app.jira_base_url |
Jira app | no default, required | base address of the Jira site; https, http only to a loopback address; an optional path prefix, no query, fragment or user information |
jira_app.jira_credentials_file |
Jira app | no default, required | file with one line <account e-mail>:<API token> for basic authentication at Jira, mode 0400 or 0600, read at each call |
jira_app.projects |
Jira app | no default, required | list of {"project": "<KEY>", "channel_id": "<forum>", "issue_type": "<name>"}, each project key once; issue_type is the issue type /issue creates; several projects may share one forum |
jira_app.state_dir |
Jira app | no default, required | state directory of the app (mode 0700): SQLite state with thread map, drafts, created objects, seen identifiers and job queue, and the lock file |
jira_app.max_body_bytes |
Jira app | 1048576 |
largest request body on each endpoint of the app |
jira_app.dedupe_window |
Jira app | 72h |
how long body digests of Jira deliveries, webhook-id values, the Jira objects the app created and open drafts are kept; keep it at least webhook.retry_max_age |
jira_app.max_queued_jobs |
Jira app | 10000 |
open jobs of the app, above which its endpoints answer 503 |