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_') reads STRAVA_ACCESS_TOKEN from .env. subjectId is accepted but ignored (single-user); no refresh, no expiry. An empty var throws TokenUnavailableError.
  • OAuth with refresh — declare the provider in defineProject({ oauthProviders: [...] }) and put ${PROVIDER}_CLIENT_ID / ${PROVIDER}_CLIENT_SECRET in .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

  1. package.json → dependencies: { "@omnislash-ai/integration-kit": "workspace:*" }.
  2. integrations/<name>.ts → defineIntegration({...}).
  3. omnislash.config.ts → integrations: [...].
  4. Token: either EnvTokenProvider + secret in .env, or OAuth via defineProject({ 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:

  1. An agent that needs a token it doesn’t have calls hitl_request_connection and pauses on an oauth_consent approval card (see human-input.md) with an authorize link. That tool provides integration:execute, and a role is offered it only when its capabilities: hold that capability (or '*') — give it to every role that mounts a connection:-backed MCP server or runs tools calling getServiceToken. A role without it gets the failure telling it to have the connection authorized from the dashboard instead, and omnislash doctor (Role declarations) warns on a connection: mount without it.
  2. 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.
  3. 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:

toolcapabilitywhat it does
service_listruntime:readevery 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_getruntime:readone 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_messagesmessaging: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_authorizeintegration:executere-authorize an OAuth connection: returns the URL the person opens, never waits; the callback revives the same connection
connection_renewintegration:executerenew a connection’s access token from its refresh token now
connection_revokeintegration:revoke (restricted, HITL)revoke a connection at its issuer and archive it
integration_list / integration_getruntime:readthe catalogue: the Official MCP Registry mirror (narrowed by integrationsStore.mcpAllowed), your integrationsStore.additional entries and your oauthProviders
integration_installintegration:executeinstall 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

  • verify REPLACES the shared WEBHOOK_SECRET for that source — it does not layer on top. That is the point: the shared secret also opens POST /tool/*, while a scoped credential opens only this source and is revocable independently. A source without verify stays protected by the shared secret.
  • Returning null from extract() gives the caller a 401, but it is a check AFTER authorization — it is not a substitute for verify. Put signature verification in verify, not in extract.
  • Authorization runs BEFORE the “unknown source” 404, so anonymous callers cannot enumerate which sources your brain declares.
  • rawBody is 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’s maxBodyBytes (default 25 MiB) is answered 413. 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 mtproto and github) in the same table, and for those the type IS a typed engine event. defineEventSource throws on such a name and event_ingest refuses it — otherwise a caller could mint a mtproto:message.received, or a github:pull_request_review.submitted that never happened, out of an arbitrary payload.
  • resources names 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 watching github: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 isWhat happensHTTP
newthe event is stored; hooks and dispatch react asynchronously202 { eventId, status: 'accepted' }
held by an event whose routing failedthe same row is revived with the new delivery’s type, payload and resources, and routed again202 { eventId, status: 'accepted' }
held by an event that is in flight or already donenothing happens200 { 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:

EventActions recorded
pull_requestopened 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_reviewsubmitted edited dismissed
pull_request_review_commentcreated edited
pull_request_review_threadresolved unresolved
issuesopened edited closed reopened labeled unlabeled
issue_commentcreated edited (on an issue or a PR’s conversation)
check_run, check_suitecompleted
push, status— (github:push, github:status)
installationcreated deleted suspend unsuspend new_permissions_accepted
installation_repositoriesadded 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:

NamePayloadWhen
github:pull_request.observedrepo, 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.settledrepo, prNumber, prUrl, headSha, conclusion (success | failure | error | none), contexts (≤ 50: kind, name, conclusion, isRequired, url — failing first, then required), failingRequired, missingRequiredevery 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.mergeabilityrepo, prNumber, prUrl, headSha, baseRef, baseSha, state (mergeable | conflicting), mergeStateStatuswhether 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' — never authorAssociation. A review App like Greptile posts with authorAssociation: 'NONE'.
  • isOwnApp: true marks your own App’s actions (its <slug>[bot] sender, its own checks, comments posted through it). Filter isOwnApp: false so 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:

  1. The agent opens the PR (gh pr create) and calls watch_create with its URL, then ends its turn. Nothing watches a PR on its own — the watch is the agent’s explicit choice (see features.md § Resource watches).
  2. 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).
  3. 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.
  4. 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.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.