Parts

Declare an event source

Validate outside deliveries and turn them into events.

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:

  1. 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 401 and costs no node any work.
  2. 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.
  3. 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

oz0-plugin.yaml
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]
FieldMeaningDefault
nameThe 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
callThe <tool>.<function> of this plugin that normalises a deliveryrequired
secretThe tenant secret deliveries are signed with, or carryrequired
signature.schemehmac-sha256, ed25519, jwt, token or plugin (below)required
signature.header, prefixWhere the signature comes, and what comes before it
signature.encodinghex or base64, for hmac-sha256 and ed25519hex
signature.timestamp, max_age_sA 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 nownone; 300
signature.messageWhat the sender signs, of {body} and {timestamp}, such as v0:{timestamp}:{body}{body}
delivery_headerThe 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 doesIdempotency-Key, then X-Request-Id
typesThe event types your function gives, for people and lint
max_body_kbThe largest delivery taken1024
repliesWhat the edge answers at once, below
bindWhat the edge checks of your function's events against the delivery, belownone: nothing matches on the events' fields

The signature schemes name no vendor:

SchemeThe edge checksFor example
hmac-sha256An HMAC-SHA256 of the raw body with the secret as its key, in header after prefix, hex or base64, compared in constant timeGitHub (X-Hub-Signature-256, sha256=), Gitea (X-Gitea-Signature, no prefix)
hmac-sha256 with message and timestampAn HMAC-SHA256 of a timestamped string, refused outside its windowSlack (v0:{timestamp}:{body} in X-Slack-Signature, v0=)
ed25519An Ed25519 signature of message, checked with the public key in the secret (hex or base64)Discord ({timestamp}{body} in X-Signature-Ed25519)
jwtA 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, belowMicrosoft's Bot Framework (Teams)
tokenThe secret itself in header, after prefixGitLab (X-Gitlab-Token)
pluginNothing: your function checks it, with the secret in its requires.secretsA 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:

oz0-plugin.yaml
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>
FieldMeaning
openid_url or jwks_urlWhere the keys are: OpenID metadata whose jwks_uri lists them, or the key set. HTTPS only.
key_hostsMore 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.
issuersThe token's iss must be one of them.
algorithmsThe token's alg must be one of them: never none and never an HMAC, whose secret would be the public key.
claims, key_fieldsWhat 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:

Terminal
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:

FieldWhat
source, idThe source's name, and the delivery's ID
headersThe request's headers, names in lowercase, without credentials and the signature (with scheme plugin, with them)
content_typeSuch as application/json
bodyThe body as JSON; for a form (application/x-www-form-urlencoded) its fields, the first value of each; else null
textThe body as text, when it is not JSON
verifiedThe facts the edge checked, such as a token's claims (scheme jwt)
sealThe 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_refusedThe 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"}]}
  • type and action say what happened in the source's own words: lowercase letters, digits, dots, dashes and underscores.
  • subject names what the event is about, one line of at most 200 bytes, such as owner/repo#12 for a pull request. Jobs wait for events by subject, so pick one that a job can know before the event comes.
  • attributes are small typed fields that filters read, at most 8 KB as JSON.
  • text is 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:

src/github_events/server.py
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.

Previous section links
Copyright © 2026