Declare an event source
For: plugin authors.
Declare an event source to turn deliveries from an outside system into events your agents can use. The edge receives a delivery at POST /events/<tenant>/<source>; a function in your plugin interprets it.
Routines and flows can start from these events, and running jobs can wait for them. The plugin defines the vendor's payload format, so adding a system requires a plugin rather than a core change.
For each delivery:
- The edge checks its size and its signature, with a tenant secret only the edge reads, before anything else sees it. A forged delivery gets
401and costs no node any work. - A node with your plugin calls your normalising function, an MCP function like any tool, with the delivery. It answers with the events it found. It runs as a call that an outside event started, in the node's sandbox where there is one.
- The edge records each event, once per delivery ID, and routes it: to the routines and flows whose event triggers fire on it, and to the jobs that wait for such an event. Every delivery and every match is in the audit trail.
Declare a source
apiVersion: oz0/v1
name: github-events
version: 1.0.0
runtime: python
tools:
- id: github_events
mcp: ["python", "-m", "github_events.server"]
affinity: any
events:
- name: github-events # POST /events/<tenant>/github-events
description: GitHub webhook deliveries
call: github_events.normalize # the function that reads a delivery
secret: GITHUB_WEBHOOK_SECRET # a tenant secret, which only the edge reads
signature:
scheme: hmac-sha256
header: X-Hub-Signature-256
prefix: "sha256="
delivery_header: X-GitHub-Delivery
types: [pull_request, pull_request_review, check_suite, workflow_run, issue_comment]
| Field | Meaning | Default |
|---|---|---|
name | The source's name in the tenant: lowercase letters, digits and dashes, at most 31. Two installed plugins with one source's name make its deliveries fail until one is removed, so start it with your plugin's name (github-events, not github). | required |
call | The <tool>.<function> of this plugin that normalises a delivery | required |
secret | The tenant secret deliveries are signed with, or carry | required |
signature.scheme | hmac-sha256, ed25519, jwt, token or plugin (below) | required |
signature.header, prefix | Where the signature comes, and what comes before it | |
signature.encoding | hex or base64, for hmac-sha256 and ed25519 | hex |
signature.timestamp, max_age_s | A header with the time the sender signed at, in Unix seconds: a delivery signed more than max_age_s from the edge's clock is refused, so an old one cannot be sent again. The signature must cover it: message holds {timestamp}, and lint refuses a timestamp with a token or without {timestamp} in message, since a time nobody signed can be set to now | none; 300 |
signature.message | What the sender signs, of {body} and {timestamp}, such as v0:{timestamp}:{body} | {body} |
delivery_header | The header with the sender's ID for one delivery, recorded with it (sender_delivery in its event.delivery entry). No signature covers it, so it never names the delivery: the body's SHA-256 does | Idempotency-Key, then X-Request-Id |
types | The event types your function gives, for people and lint | |
max_body_kb | The largest delivery taken | 1024 |
replies | What the edge answers at once, below | |
bind | What the edge checks of your function's events against the delivery, below | none: nothing matches on the events' fields |
The signature schemes name no vendor:
| Scheme | The edge checks | For example |
|---|---|---|
hmac-sha256 | An HMAC-SHA256 of the raw body with the secret as its key, in header after prefix, hex or base64, compared in constant time | GitHub (X-Hub-Signature-256, sha256=), Gitea (X-Gitea-Signature, no prefix) |
hmac-sha256 with message and timestamp | An HMAC-SHA256 of a timestamped string, refused outside its window | Slack (v0:{timestamp}:{body} in X-Slack-Signature, v0=) |
ed25519 | An Ed25519 signature of message, checked with the public key in the secret (hex or base64) | Discord ({timestamp}{body} in X-Signature-Ed25519) |
jwt | A JSON Web Token in header after prefix, signed with a key the sender publishes, from one of its issuers, for the audience in the secret, below | Microsoft's Bot Framework (Teams) |
token | The secret itself in header, after prefix | GitLab (X-Gitlab-Token) |
plugin | Nothing: your function checks it, with the secret in its requires.secrets | A sender with a scheme the edge cannot express |
Prefer hmac-sha256 or token: with plugin, every delivery, forged or not, reaches a node before it is checked, and the secret reaches your plugin's processes. plugin lint warns about it.
Bearer tokens (jwt)
Some senders sign a delivery with a token rather than an HMAC of its body: a JSON Web Token (RFC 7519) in a header, signed with a key they publish as a JSON Web Key Set, often behind OpenID metadata. The edge checks it before anything reads the delivery:
events:
- name: teams
call: oz0_teams.normalize
secret: TEAMS_APP_ID # the audience the tokens must be for, such as a bot's app ID
signature:
scheme: jwt
header: Authorization
prefix: "Bearer "
openid_url: https://login.botframework.com/v1/.well-known/openidconfiguration # or jwks_url: the key set itself
issuers: [https://api.botframework.com]
algorithms: [RS256] # the default; RS, PS and ES of 256, 384 and 512 bits may be listed
max_age_s: 300 # the clock skew allowed on exp and nbf
claims: [serviceurl] # claims your function gets as verified facts
key_fields: [endorsements] # members of the signing key it gets, as key.<member>
| Field | Meaning |
|---|---|
openid_url or jwks_url | Where the keys are: OpenID metadata whose jwks_uri lists them, or the key set. HTTPS only. |
key_hosts | More hosts the keys may come from, when the metadata's jwks_uri is on another host (*.example.com for any name under it that the domain's owner holds; a wildcard over a public suffix such as *.github.io is refused). The edge fetches keys from no other host. |
issuers | The token's iss must be one of them. |
algorithms | The token's alg must be one of them: never none and never an HMAC, whose secret would be the public key. |
claims, key_fields | What reaches your function in the delivery's verified object, checked: claims by name, the key's members as key.<member>. |
The token must be signed by a key of the set its kid names, with a key type that fits its algorithm; its aud must be the secret's value (compared in constant time), and its exp (required) and nbf must hold within max_age_s. The edge keeps a key set for the lifetime its Cache-Control gives, between five minutes and a day, and fetches it again for a kid it does not know at most once a minute, so a flood of tokens with made-up keys costs the sender, not your edge.
Two sources share a kept set only when they name the same URL, read it the same way and allow the same key hosts, and a fetch that finds no key keeps the keys the set had, so another tenant's source cannot change yours. Keys come only over HTTPS with the certificate checked, from public addresses (private ones only in dev mode or on networks --connect-allow-net lists), and each fetch is a row on Outbound connections. A token that does not pass gets 401 and is audited as event.refused once a minute; why it failed is logged without the token.
Your function gets what the edge verified in delivery["verified"], and should check the body against it: Teams'
plugin refuses an activity whose serviceUrl is not the token's serviceurl claim, or whose channel the key's
endorsements do not list.
Set the secret, and point the sender at the edge:
oz0 secret set GITHUB_WEBHOOK_SECRET --from-file ~/.secrets/github-webhook --signing --host edge.example.com:7443
oz0 event sources
SOURCE PLUGIN SIGNATURE SECRET PATH TYPES
github github-events hmac-sha256 GITHUB_WEBHOOK_SECRET /events/default/github pull_request, pull_request_review, check_suite, workflow_run, issue_comment
The edge takes a source's secret only as a signing key bound, by name, to one of its own addresses: the edge API's advertised address, its --public-edge-url, or a --san name at the edge API's port, which the operator sets, never the host a request names. A secret a connection of the same plugin saved (a GitHub App's webhook secret, a Discord public key pasted in Connect) is taken as it is, unless that connection sends it as a credential: its token, the key its tokens are signed with, its client's secret or its refresh token.
Any other secret, a credential such as a database's password, a key that signs what goes to another host or one bound by a wildcard, checks nothing, and every delivery gets the 401 a wrong signature gets, so whoever may name a source's secret cannot name another one and test guesses of it. The edge's log says why. Behind a proxy or load balancer, bind the key to the name senders use and give it to the edges as --public-edge-url.
In GitHub, add a webhook with the payload URL https://<edge>:7443/events/<tenant>/github-events, content type application/json and the same secret.
Write the normalising function
The function gets one argument, delivery:
| Field | What |
|---|---|
source, id | The source's name, and the delivery's ID |
headers | The request's headers, names in lowercase, without credentials and the signature (with scheme plugin, with them) |
content_type | Such as application/json |
body | The body as JSON; for a form (application/x-www-form-urlencoded) its fields, the first value of each; else null |
text | The body as text, when it is not JSON |
verified | The facts the edge checked, such as a token's claims (scheme jwt) |
seal | The edge's seal of this delivery's facts, when the source declares one and its when holds: carry it where the other system sends it back |
sealed, seal_refused | The facts of the seal this delivery carried back, which the edge opened, or why the one it carried did not open |
It answers with structured content or JSON text:
{"events": [{"type": "check_suite", "action": "completed", "subject": "octo/demo#12",
"attributes": {"conclusion": "success", "head_sha": "abc123"},
"text": "check_suite github-actions on abc123: completed, success"}]}
typeandactionsay what happened in the source's own words: lowercase letters, digits, dots, dashes and underscores.subjectnames what the event is about, one line of at most 200 bytes, such asowner/repo#12for a pull request. Jobs wait for events by subject, so pick one that a job can know before the event comes.attributesare small typed fields that filters read, at most 8 KB as JSON.textis what a model reads of the event, at most 16 KB. It is outside text: whatever an issue's author wrote may be in it.
One delivery may give up to 20 events, as many as its bindings tell apart: a check suite for two pull requests is one event for each, and binds their subjects as a list. A delivery that means nothing answers {"events": [], "ignore": "a ping"}, and one your function rejects answers {"refuse": "why"}, which the sender gets as 401. The edge adds source, delivery, id (the delivery's ID, with .1, .2 for the second event on) and at; your function cannot set them, so it cannot speak for another source.
The example in examples/github-events currently requires repository access; public source downloads are coming soon. It reads pull requests, reviews, check suites, workflow runs and comments without any network or secret:
from typing import Any
from mcp.server import MCPServer
from mcp.types import ToolAnnotations
from github_events.normalize import normalize as read_delivery
server = MCPServer("github-events")
@server.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False))
def normalize(delivery: dict[str, Any]) -> dict[str, Any]:
"""Turn one GitHub webhook delivery, whose signature the edge checked, into normalised events."""
return read_delivery(delivery)
A normalising function should read the delivery and nothing else: the edge may call it again for a delivery that is sent again, and its time limit is a minute.
Related tasks
- Bind and answer deliveries: Bind event identities, carry forms and choose delivery responses.
- Use platform events: Connect chat channels, inventory changes and granted plugin results.
Previous section links
- Section: answer at once
- Section: bind events to the delivery
- Section: carry a click through a form
- Section: chat channels
- Section: how deliveries are answered
- Section: the inventorys changes
- Section: the results of granted plugins