HeyAira Bridge 0.0.1 protocol¶
The complete phone notification event list is in the notification reference.
Bridge workflow dispatch and execution callbacks were removed in S5 (owner decisions
a9f0e60e and 6cab5c63). Coordinators run tasks with scripts/heyaira-run.sh and
record results with task_update. Historical workflow data remains readable;
no migration or data deletion is part of this change.
Transport and authentication¶
The Bridge initiates outbound HTTPS requests through the selected Tailscale route. The Mac does not expose an inbound listener. Tailscale provides network reachability only; the Bridge API still authenticates every request.
Pairing is a one-time code created by an operator with:
heyaira-admin create-bridge-pairing \
--project-id <project-id> \
--code-file /run/heyaira-secrets/bridge-pairing-code
The Bridge creates a random stable node_id and an Ed25519 public/private key
pair. The private key remains in the macOS Keychain. The pairing response
contains a short-lived access token and a longer-lived refresh token; the
Bridge stores both in the Keychain. PostgreSQL stores only hashes.
Endpoints¶
POST /bridge/v1/pair¶
Consumes a one-time pairing code and registers the node. The code is never logged or returned by the server. The request includes the public identity and bounded machine metadata. A successful response returns the node ID and credentials.
An optional device_id (1–256 characters, not blank) names the physical
machine and must be distinct from node_id: a stable hardware identifier
such as the Mac's IOPlatformUUID, not a value stored in the keychain (a
restored keychain carries node_id to new hardware, the hardware identifier
does not). Pairing records it as the node's device binding; a later pairing
with a fresh owner-issued code may replace it, which is how a Mac restored
onto new hardware is re-bound. Omitting it keeps any existing binding.
POST /bridge/v1/token¶
Consumes a refresh credential and returns a new short-lived access token and
the refresh credential's current refresh_expires_at. The node ID in the JSON
body must match the authenticated refresh credential. The refresh token itself
is never replaced: deployed Bridges keep the token from pairing, so a new one
would lock them out (enrolled Bridges use the two-phase rotation below). When a
renewal arrives inside the last 7 days of its validity, the server slides the
same credential's expiry to 30 days from now, but never beyond 365 days after
pairing; after that the owner pairs the Mac again, and Server Beat's
bridge.online alerts while it is offline. Until 19.10.2026 the expiry did not
slide and a running node lost its credential after 30 days.
POST /bridge/v1/heartbeat¶
Consumes the access credential and a monotonically increasing sequence number.
Duplicate or older sequences are idempotently acknowledged without changing
the node state. A newer sequence updates health and sets the node to
connected.
A heartbeat may carry the same optional device_id. A node without a binding
(paired before bindings existed) is bound by its first heartbeat that carries
one, under a row lock. Once bound, a heartbeat with a different device_id
is refused with 409 {"error": "device_mismatch"} and audited, without
changing the node; the owner re-binds by pairing again with a fresh code.
Additional health metadata from older clients remains compatible. It does not cause the server to dispatch executions. Presence and device binding still follow the heartbeat rules above.
GET /bridge/v1/node¶
Returns the authenticated node's bounded metadata and one of connected,
offline, or revoked.
GET /bridge/v1/nodes¶
Returns nodes belonging to the project selected by an existing project-scoped
HeyAira bearer token. It is an operator/API surface, not a Bridge credential
surface. The token must carry tasks:read, otherwise 403
{"error":"insufficient_scope"}.
POST /bridge/v1/nodes/{node_id}/revoke¶
Revokes the node and all of its active credentials in one transaction. The
caller must be a project owner or admin whose credential carries
bridge:bindings:write (a project token does; an ordinary OAuth grant does
not): otherwise insufficient_scope or project_admin_required (403). The
node must pair again to become trusted.
POST /bridge/v1/notifications¶
Queues one notification event for a node of the caller's project. Because the
text reaches the owner's inbox and phone, it has the same requirement as the
revoke route above: project owner or admin with bridge:bindings:write.
POST /bridge/v1/nodes/{node_id}/projects¶
Creates or restores a binding to the project selected by Authorization:
Bearer <project-token>. The actor must carry bridge:bindings:write and
be an owner/admin of that target project. Control of the enrolled Bridge
is proved by either:
- the same subject being owner/admin of its enrollment project (existing path);
X-HeyAira-Bridge-Authorization: Bearer <transport-token>containing a valid, active, unexpiredbridge:transporttoken of that exact identity, with target and enrollment projects in the same account.
The identity must be active. The extra header never grants project authority
or substitutes for the actor's required scope. Invalid/missing co-signatures,
other identities and cross-account attempts on the alternative path return
404 not_found without creating or restoring a binding.
The path first resolves an exact bridge_identities.id UUID. That match wins
over any device bridge_id collision, with no authorization fallback.
Otherwise it accepts a bridge_id only when it matches exactly one identity
globally (revoked identities count); ambiguous or missing matches return 404.
Success is 201 with {"bound":true,"node_id":"<identity-uuid>","project":...}.
No target project is accepted in the body. Tokens are never returned.
DELETE at the same path is unchanged: it takes the identity UUID and
requires only the target project's owner/admin and bridge:bindings:write,
without any co-signature. See Bridge project scope
for its cancellation effects and the Mac's project bind --project <slug>
OAuth command.
Mobile notifications (Bridge 0.0.4 boundary)¶
Mobile notifications are a project-scoped server boundary, not an iOS UI implementation. The current server accepts iOS/APNs registrations and queues safe lifecycle events. The provider is disabled by default until a deployment supplies an external secret-management boundary for APNs credentials and device-token retrieval. No APNs private key, team credential, authorization header, or raw device token is stored in PostgreSQL or returned by the API.
All mobile endpoints use the existing project-scoped Authorization: Bearer
token, which must carry tasks:read to list devices or notifications or mark
them read (otherwise 403 {"error":"insufficient_scope"}); the HeyAira app has
always requested it. The token selects the project and its subject; a device can only be
read, changed, or revoked by that same project/subject. A token hash is salted
with the project ID before persistence, and re-registering with the same
device_id replaces the environment/token hash atomically. Reusing a token
under another device is rejected.
POST /bridge/v1/mobile/devices¶
Register or replace a device. The request is accepted only for platform:
"ios" and environment: "sandbox" | "production":
{
"device_id": "optional-existing-device-uuid",
"platform": "ios",
"environment": "sandbox",
"device_token": "synthetic-or-provider-token",
"preferences": {
"severities": ["attention", "warning", "error"],
"event_types": [],
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
"quiet_hours_timezone": "Europe/Warsaw"
}
}
device_token is write-only at this boundary. The response contains only the
device ID, platform, environment, status, preferences, timestamps, and
provider: "disabled"; it never contains the token or its hash.
The default preferences deliver important events (attention, warning, and
error), accept every event type, and have no quiet hours. event_types can
restrict delivery. Quiet hours support overnight windows and an IANA timezone.
Heartbeat events are always filtered so a connected client does not create
notification spam.
The APNs deployment inputs are mounted and configured separately from the
database: HEYAIRA_APNS_KEY_FILE, HEYAIRA_APNS_KEY_ID,
HEYAIRA_APNS_TEAM_ID, and HEYAIRA_APNS_TOPIC. Set
HEYAIRA_APNS_ENABLED=true only for an explicit provider review. The delivery
loop additionally requires HEYAIRA_MOBILE_DELIVERY_ENABLED=true; production
delivery requires the separate HEYAIRA_APNS_ALLOW_PRODUCTION=true switch.
GET /bridge/v1/mobile/devices¶
Lists active and revoked devices owned by the authenticated project subject. It returns the safe registration metadata and preferences only.
PUT /bridge/v1/mobile/devices/{device_id}/preferences¶
Replaces the validated routing preferences. A revoked device cannot be modified; it must register again.
POST /bridge/v1/mobile/devices/{device_id}/revoke¶
Revokes the device and prevents future delivery. Revocation is idempotent from
the client's perspective: the resulting state is revoked.
GET /bridge/v1/mobile/notifications¶
The notification list behind the iOS inbox. Same project-scoped bearer as the
other mobile endpoints; the caller must own an active registered device,
otherwise 403 {"error":"no_registered_device"}.
Query parameters (all optional, invalid values return 400):
| name | meaning |
|---|---|
limit |
1–100, default 30 |
severity |
repeatable: info, attention, warning, error |
event_type |
repeatable, ^[a-z0-9_]{1,80}$ |
project_id |
must equal the token's project, otherwise the list is empty |
since, until |
ISO-8601 timestamps with a timezone |
unread |
true to hide items already read by this owner |
cursor |
opaque next_cursor from the previous page |
{
"items": [{
"event_id": "uuid", "dedupe_key": "task:…", "event_type": "task_blocked",
"severity": "error", "title": "Task blocked: <task title>",
"body": "Blocked. Open the task in HeyAira to see what is needed.",
"task_id": "uuid", "task_title": "…", "task_status": "blocked",
"task_next": "handoff next step or null", "task_blocker": "blocker or null",
"project_id": "uuid", "project_name": "…", "deep_link": "…",
"created_at": "2026-09-25T10:01:00+00:00", "read": false
}],
"next_cursor": null,
"unread_count": 3
}
A logical event can be stored several times (one copy per Mac that carries it,
and the project's own copy when project copies are on, see
Who gets a task event);
the list collapses those copies by dedupe_key and returns the newest. Pagination is keyset
based (created_at, event_id), newest first, so pages neither skip nor
repeat rows while new events arrive. task_title, task_status,
task_next (the handoff's next step) and task_blocker are read live from the
task, so they reflect the current state; the handoff texts are at most 600
characters, keep paragraph breaks, and are null when empty. They appear only
in this authenticated list, never in a push payload.
POST /bridge/v1/mobile/notifications/read¶
Body {"event_ids": ["uuid", …]} (at most 100) or {"all": true}; anything
else is 400. Read state is shared per project and keyed by dedupe_key
(bridge_mobile_event_reads, reader project): marking any copy read
marks the logical event read, and an item read on the iPhone is read in the
Mac Bridge app and the other way round. Ids from another project mark nothing. Idempotent.
Returns {"marked": n, "unread_count": m} where marked counts newly read
events.
GET /bridge/v1/inbox and POST /bridge/v1/inbox/read (Mac Bridge app)¶
The same list and mark-read contract as the two mobile routes above, for the
Mac Bridge app. Authentication is the Bridge node credential (reviewed
transport token or legacy node access token) instead of the OAuth actor, and
the list covers every project the node is bound to; project_id narrows it
to one of them (a project the node is not bound to yields an empty list, a
malformed id 400). Read state is the same shared per-project state as on
the phone. The node-scoped GET /bridge/v1/notifications feed that drives
desktop banners is unchanged.
Work Center reads from the iPhone app¶
GET /bridge/v1/projects, /tasks, /tasks/{id}, /tasks/{id}/workflow,
/memory, /memory/{id}, /receipts and /sources accept either the Bridge
node credential (unchanged) or the owner's project token. A project token
becomes a single-project principal for its own project and needs the matching
read scope: tasks:read (projects, tasks, workflow, receipts), memory:read
(memory) or documents:read (Git sources); without it the route answers 401.
No write is reachable this way. On /tasks/{id}/workflow a project token gets
the compact contract (instructions, prompt, each profile's soul and every
skill's content replaced by markers); a Bridge node gets the full contract.
GET /bridge/v1/sources returns only active pinned document sources by default:
every commit_sha is a full 40-character hex SHA, and the existing source fields
are unchanged. Clients that understand tracking sources can opt in with
include_tracking=true; tracking rows also contain tracking_ref (the branch
name) and commit_sha: null. Pinned rows keep their existing fields in either
mode. The same authorized project_id filter, ordering and limit/offset
pagination apply in both modes; default-mode pagination excludes tracking rows
before applying the limit and offset. Revoked sources and repositories are
excluded in both modes.
Push content¶
The notification reference lists task status
events, workflow alerts, labels, and severities. Notifications are delivered to
the node-scoped feed; default preferences do not push task_started.
Task status pushes use the readable form: title "<Label>: <task title>",
body "<Project name>: <fixed sentence>". Task descriptions, handoff text and
blocker text are never placed in a push payload; only the task title, project
name and a fixed sentence travel through Apple. The APNs payload also carries
event_id, dedupe_key, event_type, severity, task_id and deep_link
so a tap can open the list with that item expanded (match on dedupe_key: the
pushed event_id is the copy that carries the push, which the list may not
return).
Who gets a task event¶
Each Mac that serves the project gets a copy of a task event for its own feed: every Mac of the project that is not revoked, awake or not, and a Mac paired in another project that is bound to this one. So a task that stops because the Mac went away still reaches the phone and the inbox, and the Mac reads the event when it is back.
Every active registered phone of the project gets one queued push per logical
event (dedupe_key), and no phone of another project does. A revoked phone
gets nothing, and revoking a project token revokes its phones. Which copy
carries the pushes depends on the server setting
HEYAIRA_NOTIFICATIONS_PROJECT_COPIES:
- On: the event belongs to its project. It is also stored once for the
project (
node_idnull), whether or not any Mac is paired in or bound to that project. That copy is the inbox item and carries the pushes, so a project with no Mac still reaches its phones and inbox. - Off (the default): the first Mac copy carries the pushes, as servers before migration 036 did. A project with no Mac gets no event, no push and no inbox item.
The setting only decides what is written. A server reads project copies whichever way it is set, so it can be switched on in a later deploy than the one that brings the code (see Notification project copies).
A bound Mac only carries the other project's event. GET
/bridge/v1/notifications is read with the node's own pairing credential and
returns events of the node's own project only, whether the binding is active
or revoked.
Phones and projects¶
A phone registration belongs to one project and one subject. The bearer token
selects both, and an OAuth subject is always token:<project token id>, so it
names a credential of one project, not a person across projects. The device
token hash is salted with the project id, so the same physical phone
registered in two projects is two unrelated rows. Since an event belongs to
exactly one project, such a phone still gets each event once (on that
project's row), and nothing for a project it is not registered in. The iOS
app holds one OAuth grant and one device id at a time, so today it is
registered in one project; another project's events reach it only after the
app is connected to that project too. Delivering every project of one person
to one phone registration needs a person identity across projects, which the
server does not have yet.
GET /bridge/v1/notifications paging¶
Query after (a cursor) and limit (1–100, default 20). Events written in
one transaction share created_at, so the feed's position is
(created_at, event_id). next_cursor is opaque to the Bridge, which stores
it and sends it back as after. A server accepts both forms below as after
whichever way HEYAIRA_NOTIFICATIONS_PROJECT_COPIES is set. The setting only
decides which form it hands out:
- On: when a page ends where an instant ends,
next_cursoris that event's timestamp, the form an installed Bridge already has stored. A timestamp means every event of that instant has been read. When a page ends inside one instant,next_cursoris an opaque cursor that also carries the last event id, so the next page starts with the following event of the same instant. Any number of events in one instant is paged without stalling or skipping. An empty page returns the cursor it was given. - Off (the default):
next_cursoris a bare timestamp, which a server from before migration 036 can read. A page therefore never ends inside one instant: it may return more thanlimitevents, up to 500, to finish the instant. With more than 500 events in one instant the rest of that instant is still skipped, exactly as before. An empty page returnsafteras a timestamp. The one exception is anafterthat already carries an event id (handed out while the setting was on). The server then first reads the rest of that instant by event id, at mostlimitevents per page. It returns id-carrying cursors until the instant is read, then that instant's bare timestamp, so nothing is skipped when the setting is turned off.
An event's created_at is the start of the transaction that writes it, not
its commit, so an event can become visible after the feed has handed out a
later instant, or the same one. A cursor past that instant would never return
it. The feed therefore hands out only instants that have settled: earlier
than the start of the oldest transaction still open on the database, and at
least one second old. A newer event comes with a later poll, about a second
after it commits. Whichever way the setting is set, an after that carries
an event id inside an instant that has not settled yet is returned unchanged
with an empty page. The cursor forms do not change, so a server without this
rule reads every cursor this one hands out. One limit: a transaction open for
more than five minutes stops holding the feed back, so that a forgotten open
transaction cannot stall it. An event such a transaction writes and commits
after the feed has moved past its start is still skipped.
A missing after starts at the current time; a malformed one is
400 invalid_cursor.
Delivery and retry contract¶
Task lifecycle changes (in_progress, blocked, done) already create safe,
deduplicated events. For each newly-created event, the active mobile devices
of its project receive one queued delivery row on the copy that carries the
pushes, with a unique (event_id, device_id) key. A future delivery worker applies preferences and calls the provider
adapter. The adapter returns sent, retry, filtered, or failed; a
delivery gets at most five attempts in all, the first one and four retries,
so retryable failures wait 5, 10, 20 and 40 seconds (exponential backoff,
each delay capped at 300 seconds); a retryable failure on the fifth attempt
ends the delivery failed. Provider errors are stored
as bounded codes, never raw responses.
The repository includes a fake provider for synthetic tests and a disabled
provider for the default deployment. MobileDeliveryWorker.poll_once claims
at most a bounded batch using short-lived database leases, reads raw tokens
only through the injected MobileTokenVault, and finalizes rows only when
the lease still belongs to that worker. An expired lease is reclaimable after
a crash; the worker has no inbound listener or unbounded loop. The delivery
rows therefore move through queued → sent, filtered, retry, or
failed, with retry returning to the due queue and failed/filtered
being terminal for that attempt.
The production adapter boundary is ExternalEncryptedMobileTokenVault plus
ExternalAPNsProvider. The vault adapter delegates put, get, and revoke
to an injected encrypted secret store; the store owns encryption at rest,
access control, rotation, and audit logging. The adapter does not cache tokens
or write them to PostgreSQL. The APNs adapter uses a bounded HTTP/2 JWT
transport, reads the .p8 credential only from the mounted secrets path, is
disabled by default, refuses production delivery unless explicitly enabled,
and maps provider failures to a small allowlist of safe retry/error codes.
FakeAPNsProvider and test-only fake vault stores exercise the contracts
without Apple network access.
Enabling production APNs remains a separate operational step requiring an encrypted external token vault, APNs key rotation, a delivery supervisor, and an explicit production configuration review. The lease migration does not move raw device tokens into PostgreSQL.
Delivery supervisor¶
MobileDeliverySupervisor is the deployment-owned scheduler around
MobileDeliveryWorker.poll_once. It acquires one database connection per
bounded poll, waits for the configured interval after success, and applies
bounded exponential backoff after failures. It never logs exception text,
tokens, or payloads. request_stop() finishes the current poll and exits;
request_restart() exits with a restart result so a process manager can start
one replacement run. A second concurrent run() on the same supervisor is
rejected, so restart orchestration cannot accidentally create two polling
loops. The worker's short lease and SKIP LOCKED claim remain the duplicate
delivery guard if a process crashes between polls.
The server can construct the supervisor when
HEYAIRA_MOBILE_DELIVERY_ENABLED=true, a configured OpenBao vault, and an
enabled APNs provider are all present. It remains disabled by default. A safe
deployment must also set HEYAIRA_APNS_ALLOW_PRODUCTION=false for sandbox
validation and only change it after a real-device acceptance.
0.0.1 acceptance criteria¶
- an unpaired node cannot register;
- pairing is one-time and expires;
- node identity survives a Bridge restart;
- heartbeats move the node to
connected; - missing heartbeats are reported as
offline; - duplicate heartbeat sequences are safe;
- revocation blocks further heartbeats;
- reset removes local identity and credentials;
- secrets are absent from logs, API responses other than the initial pairing response, and database rows.
Recoverable Bridge access-token rotation¶
This document is the normative lifecycle contract for the project-fenced Bridge access credential. Bridge enrollment is separate from MCP OAuth and a Bridge token never grants access to another project.
Token states¶
Each stored token is pending, active, or revoked.
- Exactly one token per Bridge identity may authorize normal Bridge work.
- A pending token may call only its own rotation
confirmorabortroute. - A revoked or expired token cannot authorize work or reveal a successor.
- PostgreSQL stores token hashes only. Raw tokens appear only in the original no-store HTTP response and the Bridge Keychain.
Two-phase rotation¶
Prepare¶
POST /bridge/v1/tokens/rotate/prepare is authenticated by active predecessor
T0 and requires Idempotency-Key. It creates one short-lived pending successor
T1 and a rotation_id; T0 remains active. Exact replay while the rotation is
pending returns the same T1. A conflicting prepare returns
bridge_rotation_in_progress.
The Bridge must store one versioned Keychain journal containing T0, T1,
rotation_id, prepare and confirm idempotency keys, and the current phase
before sending confirmation.
Confirm¶
POST /bridge/v1/tokens/rotate/confirm is authenticated by T1 and carries the
matching rotation_id. The transaction verifies that the identity and T0 are
still active, activates T1, gives it a fresh normal token lifetime, revokes T0
and marks the rotation confirmed. Exact replay with active T1 returns the same
non-secret status metadata.
Abort and expiry¶
POST /bridge/v1/tokens/rotate/abort is authenticated by pending T1. Abort or
pending expiry revokes T1 but leaves T0 active. A fresh prepare may then begin
with a new idempotency key. Emergency revocation of T0 also aborts every
pending successor so a delayed confirmation cannot restore authority.
Failure recovery¶
| Failure point | Durable client state | Recovery |
|---|---|---|
| Prepare response lost | T0 + prepare key | Replay prepare exactly |
| T1 cannot be saved | T0 remains active | Do not confirm; abort or allow expiry |
| Process restarts after saving T1 | T0 + T1 + journal | Resume confirm with T1 |
| Confirm response lost | T1 + confirm key | Replay confirm exactly |
| Pending rotation expires | T0 + stale journal | Discard T1/journal and prepare again |
| Suspected compromise | Operator recovery authority | Use emergency revoke, not routine rotation |
The continuously running Bridge rotates before the active credential enters its final five minutes. Network calls are bounded and retry backoff is capped, so the local lifecycle lock cannot be held indefinitely. If an older client has already allowed the credential to expire, the supported recovery is explicit and operator-mediated:
- run
heyaira-admin recover-bridge-tokenfor the existing project and Bridge identity, writing the one-time JSON credential to a new0600file; - transfer that file through the authorized administrative channel;
- pipe it to
heyaira-bridge token recoveron the enrolled Mac; - the client validates the replacement through
GET /bridge/v1/tokens/statusbefore replacing Keychain state.
Recovery keeps the existing active identity and project binding. The server atomically aborts pending successors, revokes all predecessor tokens and installs exactly one active replacement. It is not re-enrollment, does not accept direct database edits, and refuses a client journal that contains real successor recovery evidence. The server also rejects recovery while any valid active token remains; healthy credentials must use routine rotation instead.
The retired one-phase route /bridge/v1/tokens/rotate always returns
bridge_rotation_protocol_upgrade_required without changing state. This
prevents an old client from treating a pending successor as active.
Persistence and rollout¶
Migration 016_bridge_token_rotation.sql adds explicit token state and the
rotation ledger. Existing unrevoked tokens migrate to active; revoked tokens
migrate to revoked. A partial unique index permits at most one pending
rotation per Bridge identity. A compatibility trigger maps a legacy
revoked_at-only update to state = 'revoked', so an automatic application
rollback to the immediately preceding release cannot silently lose emergency
revocation after the additive migration has committed. The same trigger rejects
that release's one-phase rotation pattern when it detects a second active
successor, so rollback cannot reintroduce the lost-response lockout.
Roll back the application before rolling back this additive schema. Do not
drop the new table or columns while a prepare/confirm-capable client is in use.
Production rollout requires an independent PASS, a database backup, the
complete additive Bridge migration chain, server deployment, then the matching Bridge release. The server
and client must not be activated independently.
Interactive task claims¶
The interactive session claim protocol remains available: create a claim with
POST /bridge/v1/claims, read it with GET /bridge/v1/claims/{claim_id} or
GET /bridge/v1/tasks/{task_id}/claim, report through
POST /bridge/v1/claims/{claim_id}/report, and release through
POST /bridge/v1/claims/{claim_id}/release. These use the reviewed node token,
active project binding and existing claim validation rules. They do not create
Bridge workflow executions.
Interactive PR CI follow-up¶
The server recovery loop keeps interactive task CI follow-up, unattended-move alerts, and expired-execution settlement (for one more release). It no longer runs Bridge workflow alerts, correction/retry offers, publication rechecks, or workflow CI polling. Signed GitHub run deliveries still update stored repository CI and wake interactive watches; runs and closed pull requests no longer advance Bridge workflows. No schema or stored data is removed.
POST /bridge/v1/tasks/{task_id}/ci-follow uses the same authenticated Bridge node
and active node-project binding as interactive claims. A claim secret is not
required; organization assignments do not grant access. The request is strict:
{
"expected_version": 4,
"pr_number": 7,
"head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"repository": "example/synthetic",
"write_context": {
"resource": "https://mcp.heyaira.eu/p/example/mcp",
"instance_id": "expected-instance",
"project_id": "00000000-0000-4000-8000-000000000002"
}
}
head_sha is required: omission or null returns head_sha_required; it must be
this session's local worktree HEAD and equal the open PR's current head.
Optional repository is GitHub owner/name and must match the task's selected
repository, case-insensitively (repository_mismatch). Bridge obtains these facts
from git rev-parse HEAD and git remote get-url origin before sending; an explicit
--sha must equal HEAD, and --repo overrides the repository it sends. A missing
Git checkout/origin fails locally without a request.
write_context
is optional for a Bridge node, which cannot learn the instance identity: the node
token and its project binding are the authority, as for claims. When sent, its
resource must be the task project's MCP address (or the legacy MCP resource), with
matching instance/project. The task's repository or its project's
sole active repository binding is used. With multiple active bindings and no task
repository, the server returns repository_required: set repository="owner/name"
using task_create or task_update. It never searches other project repositories
for a matching PR number or infers the caller's SHA from GitHub. The result is HTTP 200 with task and
organization, including organization.ci_follow_up. A stale version or workflow
task returns HTTP 409 (version_conflict, stage_managed_by_workflow). An unbound
node receives 404 for both methods. Invalid stages/inputs/context, missing links
(repository_not_linked) and unavailable GitHub App access (github_not_connected)
return 400. Execution in progress or verification enters ci; an existing CI
watch may be replaced. See settlement rules.
Release coordination: install the new Bridge and deploy the server together through
DevOps. Bridge b2705e4 without --sha receives head_sha_required after the server
upgrade (safe refusal, not backward compatible). No migration is required.
GET /bridge/v1/tasks/{task_id}/ci-follow is read-only, requires the same node
binding, and returns task_id, version, ci_follow_up (null if never followed)
and organization. Both methods send Cache-Control: no-store. A watch includes
id, task_id, project_id, repository_id, pr_number, head_sha,
state (watching|passed|failed|unfinished|cancelled), runs_summary, polls,
poll_after, created_by, created_at, settled_at. Each run contains only
id, workflow_id, name, status, conclusion, html_url, updated_at.
Clients can wait until the watch is terminal; replacing a watch cancels the old
one and GET reports the latest. These endpoints do not start or wake a session.