Integrations
Declarative bundle — the brain describes an integration as one object (tools + webhooks + token provider), and the engine automatically registers the tools on every surface (chat, MCP, pipelines, worker, webhooks) and mounts the webhook routes. No per-app wiring in your code.
Related: voice.md (the transcription.chain / speech.chain config this pattern
mirrors), human-input.md (the oauth_consent approval card), features.md.
Surface
// integrations/dummy.ts (in your brain repo)
import { createTool, success } from '@omnislash-ai/config/tools';
import { defineIntegration } from '@omnislash-ai/integration-kit';
import { z } from 'zod';
const echoTool = createTool({
name: 'dummy_echo',
description: 'Echo the provided message back.',
schema: z.object({ message: z.string() }),
risk: 'safe',
// Every tool must provide a capability — `[]` fails registration. Roles reach
// the tool by listing it in `capabilities:`.
providesCapabilities: ['dummy:read'],
async execute({ message }) {
return success(`echo: ${message}`);
},
});
export const dummyIntegration = defineIntegration({
name: 'dummy',
tools: [echoTool],
webhooks: [
{
path: 'ping',
method: 'POST',
async handle(req) {
return { status: 200, body: { received: req.body } };
},
},
],
});
// omnislash.config.ts
import { defineProject } from '@omnislash-ai/config/public';
import { dummyIntegration } from './integrations/dummy.ts';
export default defineProject({
name: 'my-brain',
integrations: [dummyIntegration],
// ...
});
// constitution/project-capabilities.json — declare the capability the tool provides
{
"dummy": {
"read": { "risk": "safe", "hitl": false, "description": "Echo a message back" }
}
}
# features/team/support.agent.md — a role that should use the tool lists it
capabilities: [dummy:read]
Do not call registerProjectTools for integration tools — the engine registers them on all
surfaces itself. Every role whose capabilities: holds dummy:read (or '*') sees dummy_echo;
a role without it is neither shown the tool nor allowed to call it.
Contracts
interface Integration {
name: string; // /^[a-z0-9-]+$/ — used as a URL segment
tools: Tool[];
webhooks?: WebhookHandler[];
}
interface WebhookHandler {
path: string; // no leading slash; mounted at /webhooks/{name}/{path}
method?: 'POST' | 'GET' | 'PUT' | 'DELETE'; // default POST
verifySignature?: (req: WebhookRequest) => boolean | Promise<boolean>;
handle: (req: WebhookRequest) => Promise<WebhookResponse>;
}
interface WebhookRequest {
headers: Record<string, string>; // lowercased
rawBody: Buffer; // exact bytes — use this for HMAC
body: unknown; // parsed JSON (if content-type matches), else null
query: Record<string, string>;
}
interface WebhookResponse {
status: number;
body?: unknown; // undefined → empty response; otherwise JSON
}
interface TokenProvider {
getAccessToken(subjectId: string): Promise<string>;
}
defineIntegration validates at load: non-empty pattern-matching name, unique tool names within
the integration, non-empty webhook paths without a leading slash, unique (method, path) pairs.
A violation throws IntegrationValidationError — your brain fails fast at boot, not at first call.
Webhook routing
Routes mount at POST /webhooks/{integration.name}/{handler.path} on the engine’s webhooks
service. Per request: raw bytes are captured before parsing (so HMAC over rawBody works), headers
are lowercased, JSON bodies are parsed when content-type matches (else body is null), then
verifySignature runs — a false return is a 401 and handle is never called; a handle
exception is a 500.
There is no bearer auth on webhook routes — a webhook endpoint is public by nature, and your
verifySignature (HMAC) is the protection. This is the same standard Stripe, GitHub and Telegram
use. If your provider signs requests, implement verifySignature; if it doesn’t, treat the payload
as untrusted input.
Tokens: two providers, one interface
Your tools call getAccessToken(subjectId) and never know which implementation is wired.
EnvTokenProvider(from@omnislash-ai/integration-kit) — zero-infra:new EnvTokenProvider('STRAVA_')readsSTRAVA_ACCESS_TOKENfrom.env.subjectIdis accepted but ignored (single-user); no refresh, no expiry. An empty var throwsTokenUnavailableError.- OAuth with refresh — declare the provider in
defineProject({ oauthProviders: [...] })and put${PROVIDER}_CLIENT_ID/${PROVIDER}_CLIENT_SECRETin.env. The engine’s database-backed token provider is wired automatically: per-subject storage, encrypted at rest, reactive refresh under a lock. You write the declaration, not a provider.
OAuth is deliberately not declared on the Integration object — the provider lives in
defineProject({ oauthProviders }), and tools reach tokens only through TokenProvider.
Wiring checklist
package.json→dependencies: { "@omnislash-ai/integration-kit": "workspace:*" }.integrations/<name>.ts→defineIntegration({...}).omnislash.config.ts→integrations: [...].- Token: either
EnvTokenProvider+ secret in.env, or OAuth viadefineProject({ oauthProviders })+ client credentials in.env.
Smoke test from your brain root:
omnislash dev
curl -X POST http://localhost:$WEBHOOK_PORT/webhooks/dummy/ping \
-H 'content-type: application/json' -d '{"hello":"world"}'
# → 200 { "received": { "hello": "world" } }
Then call dummy_echo through the bot or MCP — a reply of echo: <msg> confirms registration on
every surface.
Connecting a provider — no public ingress required
An authorization server never connects to redirect_uri — it 302s the person’s browser
there, and the code→token exchange plus every refresh are outbound calls from the server. So a
deployment with zero inbound ports can complete OAuth and refresh forever. The flow:
- An agent that needs a token it doesn’t have calls
hitl_request_connectionand pauses on anoauth_consentapproval card (see human-input.md) with an authorize link. That tool providesintegration:execute, and a role is offered it only when itscapabilities:hold that capability (or'*') — give it to every role that mounts aconnection:-backed MCP server or runs tools callinggetServiceToken. A role without it gets the failure telling it to have the connection authorized from the dashboard instead, andomnislash doctor(Role declarations) warns on aconnection:mount without it. - An owner opens the link and authorizes at the provider. The provider redirects the browser to the callback URL — on a no-ingress box that page fails to load. That is expected: the URL in the address bar is the payload.
- They reply to the consent card with the full address-bar URL (or use the CLI, below). The engine forwards it server-side, exchanges the code, stores the connection, and the paused agent wakes.
A paste spends the link whatever the outcome — on failure ask for a fresh card, never retry the
same URL. A denied consent (?error=access_denied in the pasted URL) is forwarded too and cleanly
resolves the request. The window is 30 minutes.
CLI equivalents: omnislash connect provider <id> [--prod] for brain-declared providers (always
connects as the deployment-wide subject dash:default — deliberately no subject flag), and
omnislash connect mcp <url> --subject <subject> [--prod] for MCP servers. --prod runs the same
command inside the production webhooks container over SSH with a real TTY for the paste; it needs
PROD_SERVER_HOST + PROD_PROJECT_DIR in the brain .env. A typo’d provider id gets an error
listing what IS declared.
Robot authorization (autoAuthorize)
The only human step is driving a browser — a provider can opt into the engine driving it instead:
// defineProject({ oauthProviders })
{ name: 'strava', /* … */ autoAuthorize: true }
The engine then spawns a browser agent to open the authorize URL, log in with
<UPPER_SNAKE(name)>_LOGIN_EMAIL / _LOGIN_PASSWORD (declare them in your brain’s env schema
extension), clear an emailed one-time code if the provider sends one, approve consent, and complete
the flow. The attempt is skipped silently unless the provider opted in, both credential vars are
set, and a Playwright MCP endpoint is configured. Failure costs nothing — the human consent
card is minted regardless, so every failed robot attempt just leaves the normal flow standing.
Opt in knowing: the login password ends up in the browser agent’s prompt and outlives the run in engine storage (agent history, execution journal, CLI session transcript) — use a login scoped to that one provider, and only where robot login is acceptable. Providers with bot detection or hardware MFA will fail and fall back; that is expected, not a bug.
Services and connections as tools
Every external service the deployment acts through — an MCP server, a brain-declared OAuth
provider, an integration bundle, a Telegram user account — is one service, keyed
<type>:<name> (mcp:<host>[~<path>], oauth:<provider>, integration:<name>,
telegram:<account>). An agent reads and manages them with the same tools the dashboard’s
buttons call:
| tool | capability | what it does |
|---|---|---|
service_list | runtime:read | every service: its state (active, needs_reconnect, expired, revoked, unconfigured), accounts (never a token), the tools it brings (an MCP server’s: the ones its agents called in the window), which features used them over a window, its circuit breaker and credential rows |
service_get | runtime:read | one service by key or by any of its connections (conn_…, a revoked one included), with every tool it brings and its circuit’s recent events |
service_list_messages | messaging:read | ”Sent as you”: what agents sent from a Telegram user account, newest first, with the text read from Telegram for the page (never stored); refused for a turn that answers one chat (a secretary’s business chat, a chat-scoped hook) — the sends span every chat of the account |
connection_authorize | integration:execute | re-authorize an OAuth connection: returns the URL the person opens, never waits; the callback revives the same connection |
connection_renew | integration:execute | renew a connection’s access token from its refresh token now |
connection_revoke | integration:revoke (restricted, HITL) | revoke a connection at its issuer and archive it |
integration_list / integration_get | runtime:read | the catalogue: the Official MCP Registry mirror (narrowed by integrationsStore.mcpAllowed), your integrationsStore.additional entries and your oauthProviders |
integration_install | integration:execute | install a catalogue entry for the whole deployment: an OAuth entry answers outcome.kind: redirect with the URL to open, an open server connects at once (connected); an entry that needs a typed token is refused — a person enters it on the dashboard, so the secret never passes through a tool |
connection_authorize and integration_install need WEBHOOKS_PUBLIC_URL (the origin the
provider sends the browser back to). An agent that must wait until the person finishes keeps
hitl_request_connection — it pauses on the consent card; connection_authorize only hands back
the link. Every send through a Telegram user account’s tools (telegram_send, a forward, a
telegram_scheduled send-now) is an action-log row telegram.send against the chat, attributed
to the calling agent — what service_list_messages and the action log (audit_list) read.
When a connection dies
An OAuth connection dies when its refresh token is rejected or it has none to use. The death is a
notification, not an approval request: the status chat gets a message naming the integration,
the cause, and the fix (usually reconnect via the dashboard or the omnislash connect command the
message itself renders). Agents that try to use the dead connection get a durable tool failure
telling them to request re-authorization — an agent holding integration:execute does that
itself, which raises the oauth_consent card and runs the loop above. Re-connecting always revives the existing connection in place; it never creates a duplicate.
Event sources (defineEventSource)
Inbound events arrive at POST /event/:source on the webhooks service. There is no provider-specific code in the engine — extraction and verification live entirely in your brain config.
Declare sources in a separate module (it may read env, unlike omnislash.config.ts itself) and wire them in by import:
// features/billing/event-source.ts
import { bearerToken, defineEventSource } from '@omnislash-ai/config/public';
import { env } from '../../env.ts';
// HMAC provider: no credential of its own — the signature over rawBody IS the auth.
export const stripeSource = defineEventSource({
name: 'stripe',
verify: ({ headers, rawBody }) =>
verifyHmac(rawBody, headers['stripe-signature'], env.STRIPE_WEBHOOK_SECRET),
extract({ body }) {
const event = body as StripeEvent;
return {
type: event.type, // 'payment_intent.succeeded'
payload: event.data.object,
idempotencyKey: event.id, // optional — SHA-256 fallback otherwise
};
},
});
// Your own reporter: scoped credential for an untrusted device;
// the shared secret stays valid for trusted callers.
export const sensorSource = defineEventSource({
name: 'sensor',
verify: bearerToken(env.SENSOR_INGEST_SECRET, env.WEBHOOK_SECRET),
extract: ({ body }) => ({ type: 'sensor.report', payload: body as Record<string, unknown> }),
});
// omnislash.config.ts
import { defineProject } from '@omnislash-ai/config/public';
import { stripeSource, sensorSource } from './features/billing/event-source.ts';
export default defineProject({
ingest: { sources: [stripeSource, sensorSource] },
});
Types:
interface EventExtractContext {
headers: Record<string, string>; // lowercased
rawBody: Buffer; // exact bytes, for HMAC
body: unknown; // parsed JSON (null if not application/json)
}
interface EventSourceDefinition {
name: string; // not an engine-reserved source (see below)
verify?: (ctx: EventExtractContext) => boolean | Promise<boolean>;
extract: (ctx: EventExtractContext) => ExtractedEvent | null | Promise<ExtractedEvent | null>;
maxBodyBytes?: number; // default 25 MiB; larger bodies get 413 before verify
}
interface ExtractedEvent {
type: string;
payload: Record<string, unknown>;
idempotencyKey?: string; // auto-generated SHA-256 if omitted
resources?: string[]; // correlation keys, e.g. 'github:pr:acme/app#7'
}
/** Timing-safe `Authorization: Bearer` check against any of the secrets. Empty list — throws at config load. */
function bearerToken(...secrets: string[]): NonNullable<EventSourceDefinition['verify']>;
Who gets through
verifyREPLACES the sharedWEBHOOK_SECRETfor that source — it does not layer on top. That is the point: the shared secret also opensPOST /tool/*, while a scoped credential opens only this source and is revocable independently. A source withoutverifystays protected by the shared secret.- Returning
nullfromextract()gives the caller a 401, but it is a check AFTER authorization — it is not a substitute forverify. Put signature verification inverify, not inextract. - Authorization runs BEFORE the “unknown source” 404, so anonymous callers cannot enumerate which sources your brain declares.
rawBodyis captured before any parsing — sign/verify against those exact bytes, never against re-serialized JSON.- The body is size-capped before anything else runs — before
verify, before it is buffered: a request over the source’smaxBodyBytes(default 25 MiB) is answered413. Raise it only for a provider that genuinely sends more. - Some source names are the engine’s. The engine records events of its own sources (today
mtprotoandgithub) in the same table, and for those thetypeIS a typed engine event.defineEventSourcethrows on such a name andevent_ingestrefuses it — otherwise a caller could mint amtproto:message.received, or agithub:pull_request_review.submittedthat never happened, out of an arbitrary payload. resourcesnames what the event is about, in the engine’s grammar —github:pr:<owner>/<repo>#<n>,github:issue:<owner>/<repo>#<n>,github:commit:<owner>/<repo>@<sha>,github:ref:<owner>/<repo>:<ref>(owner/repo lowercase). It is stored on the event’s row, and it is what a resource watch matches: an agent watchinggithub:pr:acme/app#7(watch_create) or a pipeline waiting on it (subscribeResource, pipelines.md) is woken by your event exactly as by a GitHub delivery (features.md § Events System). Omit it when the event is about no named resource.
The idempotency key is a permanent claim
Read this before you pick a key, because senders retry and backfill.
Every ingested event is one row in omnislash.external_events, keyed by
(source, idempotencyKey), and Postgres enforces that pair as unique —
forever, with no expiry. The insert and the duplicate check are the same
statement, so two deliveries racing each other cannot both get through.
Three outcomes, and the sender can tell them apart:
| The key is | What happens | HTTP |
|---|---|---|
| new | the event is stored; hooks and dispatch react asynchronously | 202 { eventId, status: 'accepted' } |
| held by an event whose routing failed | the same row is revived with the new delivery’s type, payload and resources, and routed again | 202 { eventId, status: 'accepted' } |
| held by an event that is in flight or already done | nothing happens | 200 { status: 'duplicate' } |
The failed revival matters as much as the dedup: without it, an engine-side
failure would turn the sender’s correct retry into a permanent silent
duplicate, and the event would be lost for good.
Omitting idempotencyKey does not opt out. The engine derives one by
hashing { source, type, payload }, which makes a byte-identical re-POST a
duplicate by construction. There is no way to have no key, and no reason to
want one: a row without a key is a row nothing dedups.
So choose the key as a statement about what “the same event” means. A key
per delivery attempt dedups nothing. A key per bucket
(${device}:${hourBucket}) collapses a backfill of that bucket into one row,
however many times the reporter re-sends it and however late — which is usually
what you want from a device that reconnects and catches up.
The flip side, and it is a real constraint: “re-send it tomorrow so it gets processed again” no longer works. There is no TTL and no knob to bring one back. If a sender legitimately needs the same content processed twice, the key has to say so — put the occasion in it (a period, a run id, an attempt number), because that is what “the same event” now means.
202 means recorded, not “handled”: the row is durable the moment the
call returns, and everything that reacts to it does so asynchronously. A row
whose routing has not started within a minute (the process died right after
the write, Redis blinked) is picked up by the engine’s sweep — an accepted
event is never stuck.
After the door
A stored (or revived) event is routed by one durable job in the worker: it
fires every *.hook.md on ingest:event whose filter matches (one hook run
per event, keyed by the row — a retry or a re-route never fires twice), runs
your outbound dispatch targets that subscribe to ingest:event, and only
then publishes ingest:event on the event bus, carrying { eventId, source, type, payload, timestamp } (timestamp = when the row was first written). A
bus observer (events().observe('ingest:event', handler)) is just that: it can
miss an event while it reconnects. React with a *.hook.md that names an
agent — which is how an alert becomes an agent run with no glue code at all:
---
event: ingest:event
filter:
source: grafana # matches the event's top-level `source`
agent:
role: necromancer
---
A {{type}} arrived from {{source}}:
{{payload}}
Diagnose it, and fix it only if the fix is reversible.
Two things about that template, because both are easy to get wrong. filter
matches the event’s top-level fields, so source and type are what you
can select on — not something nested inside your payload; if you need to route
on a field, type is the place to put it (that is what it is for). And the
placeholders are flat names, not paths: {{eventId}}, {{source}},
{{type}}, {{payload}}, {{timestamp}}, plus the {{eventName}} /
{{hookName}} every hook body gets. {{payload}} renders as JSON — hand
the agent the whole body and let it read it. Both {{payload}} and {{type}}
reach the prompt fenced as untrusted outside content (the sender wrote
them, and type is usually lifted from the request): the agent sees them as
data, not as instructions. A dotted {{payload.status}}
resolves to nothing and is left in the prompt verbatim, on purpose, so the typo
is visible instead of silently empty. The same frontmatter shape drives every
other hook — see human-input.md § Consuming the response.
The row lives in the engine’s own schema and your agents can read it with
db_query — qualified, omnislash.external_events, since search_path is
pinned to public (database.md). Useful columns: status
(received → processed | failed), error, processed_at, payload,
resources and idempotency_key. received = recorded, not yet routed;
processed = every hook and dispatch target was handed the event; failed =
routing gave up (or the row is unreadable) — error says why, and a
re-delivery of the same key routes it again. A duplicate delivery writes no row
at all. There is no page rendering these rows; the dashboard’s Connections › Inbound
(/connections/inbound) shows today’s volume and error count per source.
The door itself records the event through the same function the engine’s
event_ingest tool runs (the event.ingest op) — no tool call, no actor. A source is not a place to put
behavior — behavior belongs in the hook the event triggers.
Nothing prunes this table, on purpose. It is the dedup ledger: deleting
old rows silently re-opens every key they held. If you ever add a cleanup job,
treat it as a correctness decision, not housekeeping. (The engine’s own sources
empty the payload of a handled row after a brain-tunable window — the row,
its key and its resources stay.)
GitHub events (github:*)
When the deploy has a GitHub App (registered by an agent’s git_request_app card or from the dashboard under Connections › Repos, /connections/repos),
every delivery GitHub sends it lands in the same inbound table under the
reserved source github, as one event of a closed, typed family — and fires
your hooks like any other event. No defineEventSource is involved: the
engine verifies, normalizes and records the delivery itself.
Names: github:<event>.<action> — or github:<event> for the two events
without an action:
| Event | Actions recorded |
|---|---|
pull_request | opened reopened closed synchronize edited ready_for_review converted_to_draft review_requested review_request_removed labeled unlabeled auto_merge_enabled auto_merge_disabled enqueued dequeued |
pull_request_review | submitted edited dismissed |
pull_request_review_comment | created edited |
pull_request_review_thread | resolved unresolved |
issues | opened edited closed reopened labeled unlabeled |
issue_comment | created edited (on an issue or a PR’s conversation) |
check_run, check_suite | completed |
push, status | — (github:push, github:status) |
installation | created deleted suspend unsuspend new_permissions_accepted |
installation_repositories | added removed |
Anything else — another event, another action, ping — is answered and
recorded nowhere. A redelivery (GitHub keeps the X-GitHub-Delivery id) is a
duplicate and fires nothing twice.
Engine-derived members. A few names in the family are never sent by GitHub:
the engine records them itself, through the same table, when it learns a fact
some other way — so hooks see them exactly like a delivery. Each carries
derived: true:
| Name | Payload | When |
|---|---|---|
github:pull_request.observed | repo, prNumber, prUrl, state (open | closed | merged) | the engine read a PR’s state itself — repairing a delivery that never arrived. One per PR and state |
github:checks.settled | repo, prNumber, prUrl, headSha, conclusion (success | failure | error | none), contexts (≤ 50: kind, name, conclusion, isRequired, url — failing first, then required), failingRequired, missingRequired | every check run and status on the PR’s head reached an outcome. One per head and outcome set: a re-run that ends the same way is a new one, a re-read of the same results is not. none = the head carried no checks at all for 10 minutes (a repository without CI) |
github:pull_request.mergeability | repo, prNumber, prUrl, headSha, baseRef, baseSha, state (mergeable | conflicting), mergeStateStatus | whether the PR merges cleanly changed — GitHub sends no event when a push to the base makes a PR conflict. The first mergeable reading is silent, the first conflicting is not |
How the derived signals are read. GitHub has no “all checks done” and no
“now conflicts” event, so the engine probes the PR — one GraphQL read of its
check rollup (with whether this PR requires each check) and its mergeability
— and records what the read shows. A probe runs 30 s after the last check run,
check suite or status on the PR (a burst of CI completions is one probe), after
a PR is opened, reopened or pushed to, every 5 minutes for every watched PR,
and on any push to the repository of a watched PR (a push to its base is how a
conflict appears). checks.settled waits until ALL of the head is terminal —
not the first failing required check — so a head gives one signal. A required
status nobody posts (GitHub’s expected) does not hold it back forever: the
signal names it in missingRequired. The probes, the debounce and the grace
window are engine cadence, not settings. Like every GitHub event, a derived one
is a cue to re-read (gh pr checks, gh pr view --json mergeable), and it
reaches watches and hooks the same way a delivery does — a watch drops a
checks.settled about a head a push already replaced, like a stale check run.
Missed deliveries are recovered. GitHub never retries a failed delivery by
itself. Every 5 minutes (and at boot) the worker reads the App’s delivery log
back to where it last read, finds every delivery that has no successful
attempt and no row here — the receiver was down, rejected it, answered too
slowly — and pulls its payload straight from GitHub into the same table,
exactly as if it had arrived. Nothing is recorded twice (the delivery id is
the key). When that happens the doctor check GitHub webhook delivery warns
with the newest recovered delivery and GitHub’s account of the failed attempt
(401 — the App’s webhook secret differs from the deploy’s, 413 — a body
over the cap, a timeout — the URL is unreachable), because a receiver that
loses deliveries is still broken; it also warns when the reconciler itself has
stopped running (with the newest failed pass’s error) and when GitHub listed
delivery attempts in a shape the engine cannot read.
The webhook URL and secret are the engine’s. They are the one part of a GitHub App’s settings the App can change itself, so the worker sets them (at boot and daily): a deploy holding no secret mints one and sets it on both sides, a missing URL is pointed at this deploy’s receiver, and a held secret is pushed again on every run, so the two copies cannot drift apart for long. A run that fails is quoted by the doctor. A URL pointing somewhere else is left alone and reported. Events, permissions and the webhook’s “Active” box have no API — the doctor check GitHub App subscription spells those out, page by page.
Payload — flat. A hook’s filter and the prompt fence see top-level fields
only, so everything worth selecting on is a top-level scalar. Every repository
event carries id (the delivery id), installationId, repo (owner/name,
lowercase), repoId, senderLogin, senderType, isOwnApp. PR-scoped
events add prNumber, prUrl, headSha, headRef, baseRef, isFork;
authored ones add authorLogin, authorType, authorAssociation. Then per
event: a PR’s title, body, state, draft, merged, mergedAt,
autoMerge, labels, label, requestedReviewer, before; a review’s
reviewId, state (approved | changes_requested | commented |
dismissed), body, commitId, reviewUrl; an inline comment’s
commentId, reviewId, inReplyToId, path, line, body, commitId,
commentUrl; a thread’s threadNodeId, commentIds; an issue’s
issueNumber, issueUrl, title, body, state, labels, label; a
conversation comment’s issueNumber, issueUrl, isPullRequest, prNumber,
prUrl, headSha (always null — GitHub sends none), title, commentId,
body, commentUrl, performedViaAppId; a check run’s checkRunId,
checkSuiteId, name, headSha, conclusion, appId, appSlug,
prNumbers, outputTitle, outputSummary, detailsUrl, htmlUrl; a status’s
headSha, context, state, description, targetUrl; a push’s ref,
before, after, forced, created, deleted. A check’s log
(output.text), a comment’s diff_hunk and a push’s commit list are not
recorded — read them with gh when needed.
Three rules worth knowing before you write a filter:
- Bot or human is
senderType/authorType='Bot'— neverauthorAssociation. A review App like Greptile posts withauthorAssociation: 'NONE'. isOwnApp: truemarks your own App’s actions (its<slug>[bot]sender, its own checks, comments posted through it). FilterisOwnApp: falseso an agent’s own reply does not wake it again.- A filter value may be a list — any-of:
authorAssociation: [OWNER, MEMBER, COLLABORATOR].
---
event: github:pull_request_review.submitted
filter:
repo: acme/app # lowercase
isOwnApp: false
agent:
role: developer
---
{{authorLogin}} reviewed {{prUrl}} ({{state}}):
{{body}}
Everything another person wrote — titles, bodies, comments, branch and label
names, check output — reaches the prompt fenced as untrusted outside content.
Logins and ids are not fenced. A review is a signal to look, not the truth:
GitHub delivers each event at most once, so re-read the PR (gh pr view)
before acting on it.
Resource keys. Each row carries the keys of what it is about in
resources: github:pr:<owner>/<repo>#<n> for PR events (and for a
conversation comment on a PR), github:issue:<owner>/<repo>#<n> for issues,
github:commit:<owner>/<repo>@<sha> for PR events, checks and statuses, and
github:ref:<owner>/<repo>:<ref> for pushes (full ref, refs/heads/main).
A check or status GitHub ties to no PR — every check of a PR from a fork, and
every status — is joined to its PR through the newest recorded PR event with
the same head commit. A brain source without the App can emit the same keys
from extract → resources. These keys are what resource watches match: an agent that opened a
PR calls watch_create with its URL and gets the reviews, comments, checks
and the merge as batched turns; a pipeline waits with subscribeResource.
When the engine itself reads a PR outside any delivery (the loop’s orphan
sweep finding a merge nobody reported, a probe) it records the derived member
— github:pull_request.observed, github:checks.settled,
github:pull_request.mergeability — under the PR’s key.
Subscription. A new App subscribes to every event above at registration
and asks for checks: read and issues: read beside the engine’s other
permissions; git.appPermissions in omnislash.config.ts adds or raises
levels (the stronger level wins). GitHub offers no API to change an existing
App’s events or permissions, so an App registered earlier with fewer is fixed
once by a person in the App’s settings: the doctor check GitHub App
subscription (doctor_check) returns that exact message — the page, the
levels, the checkboxes by their GitHub labels, then each installation’s page to
accept the new permissions — and the worker sends it to the status chat once
per distinct drift (at boot and daily). Installation lifecycle deliveries keep
the deploy’s installation list current on their own.
Retention. A handled GitHub row’s payload is emptied after
github.payloadRetentionDays in constitution/MANIFEST.yaml (engine default
30 days); the row, its delivery id and its resource keys stay.
GitHub PR feedback loop
The pieces above compose into the loop a coding agent runs on its own pull request — no hook, no per-PR configuration:
- The agent opens the PR (
gh pr create) and callswatch_createwith its URL, then ends its turn. Nothing watches a PR on its own — the watch is the agent’s explicit choice (seefeatures.md§ Resource watches). - Reviews, comments, CI results (
github:checks.settled), a new conflict (github:pull_request.mergeability) and the merge arrive as one batched turn each, fenced as untrusted content and from trusted authors and bots only (echoes of the agent’s own App are dropped). - The batch is a cue: the agent re-reads the PR with
gh(reviews, threads, checks), fixes what it agrees with in one commit, answers every thread in one review, resolves what it fixed, and re-requests the bot reviewers. - The watch ends with a final batch when the PR closes, or when its TTL or delivery budget runs out — the agent then extends it, asks a person, or stops.
It needs a CLI agent (gh and a checkout; the engine hands gh an
installation token — no gh auth login). brain-example/skills/github
carries a ready skill for it (references/pr-feedback.md): how to tell a bot
reviewer is done with the current head (its review’s commit_id equals the
head; Greptile’s summary links the head commit), which threads to fix, decline
or escalate to a person, and a round limit after which it asks a person
instead of looping.