gRPC API
The Foundry service is defined in proto/foundry.proto.
Service: Foundry
Emit(EmitRequest) → EmitResponse
Fire an event into the system. The engine spawns processing as a background task
and returns the event ID immediately. Use Trace to check for completion,
Status to see in-flight workflows, or Watch for real-time event streaming.
Request:
| Field | Type | Description |
|---|---|---|
event_type | string | Event type name |
project | string | Target project |
throttle | Throttle enum | THROTTLE_FULL, THROTTLE_AUDIT_ONLY, THROTTLE_DRY_RUN |
payload_json | string | Optional JSON payload |
Response:
| Field | Type | Description |
|---|---|---|
event_id | string | Deterministic ID of the created event |
workflow_id | string | ID of the triggered workflow (if any) |
Status(StatusRequest) → StatusResponse
Query active workflow states.
Request:
| Field | Type | Description |
|---|---|---|
workflow_id | string | Specific workflow (empty for all active) |
Response:
| Field | Type | Description |
|---|---|---|
workflows | repeated WorkflowStatus | Active workflow states |
Watch(WatchRequest) → stream WatchResponse
Server-side streaming of live events as they are processed by the engine. Optionally filtered by project name.
Request:
| Field | Type | Description |
|---|---|---|
project | string | Project name to filter by; empty string for all projects |
Response (stream):
| Field | Type | Description |
|---|---|---|
event_id | string | Event identifier |
event_type | string | Event type name |
project | string | Target project |
payload_json | string | Event payload as JSON |
RegistryAdd(RegistryAddRequest) → RegistryAddResponse
Add a project to the daemon's in-memory registry and persist the change to
registry.json. The daemon is the single source of truth for registry state.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Unique project name |
path | string | Absolute path on the local filesystem |
stack | string | Technology stack: rust, python, typescript, elixir, cpp, swift, kotlin |
agent | string | AI agent name |
repo | string | GitHub repo slug (owner/repo) |
branch | string | Default branch (empty → main) |
iterate | bool | Enable iterate action |
maintain | bool | Enable maintain action |
push | bool | Enable push action |
audit | bool | Enable audit action |
release | bool | Enable release action |
install_command | string | Shell command for local install (mutually exclusive with install_brew) |
install_brew | string | Homebrew formula for local install (mutually exclusive with install_command) |
notes | string | Human-readable notes (empty → none) |
timeout_secs | uint64 | Per-project timeout (0 → use default 3600 s) |
update_policy | string | patch, minor or major (empty → not set; maintenance behaves as minor) |
installs_skill | string | true, false, or a skill-install command (empty → not set; blank → INVALID_ARGUMENT) |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The newly created project entry |
Errors: ALREADY_EXISTS if the name is already in the registry;
INVALID_ARGUMENT for an unknown stack, an unknown update policy, or
conflicting install fields;
INTERNAL with the stable message failed to persist registry state when
saving fails.
CLI: foundry registry add ... routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to bypass the daemon and
mutate the registry file directly. Without --offline, an unreachable daemon
returns an error and leaves the client-side registry file unchanged. The online
path mutates daemon-owned state only and does not create
FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL
with a stable failed to persist registry state message and leaves both the
daemon's in-memory registry and its on-disk registry bytes unchanged.
RegistryList(RegistryListRequest) → RegistryListResponse
List the daemon-owned registry inventory from the in-memory state held by
foundryd. The online CLI path renders this response directly and does not read
FOUNDRY_REGISTRY_PATH.
Request:
This message has no fields.
Response:
| Field | Type | Description |
|---|---|---|
projects | repeated Project | Every project currently loaded in the daemon-owned registry |
Each Project carries the full registry data required by online clients:
name, path, stack, agent, repo, branch, skip, action flags,
install config, notes, timeout, installs-skill state, audit exceptions, and
update_policy (empty when not set).
Errors: None at the RPC layer; the daemon answers from already-loaded registry state.
CLI: foundry registry list routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to read the registry
file directly. Without --offline, an unreachable daemon returns an error and
leaves the client-side registry file unchanged. The online path renders the RPC
response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is
absent, the online path leaves it absent. foundry registry init is not part of
this RPC surface and remains an offline-only recovery command.
RegistryShow(RegistryShowRequest) → RegistryShowResponse
Retrieve one exact-name project from the daemon-owned registry state. The match is exact and does not perform prefix or substring lookup.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact project name to retrieve |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The full daemon-owned project record for that exact name |
Errors: NOT_FOUND if no exact-name project exists.
CLI: foundry registry show <name> routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to read the registry
file directly. Without --offline, an unreachable daemon returns an error and
leaves the client-side registry file unchanged. The online path renders the RPC
response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is
absent, the online path leaves it absent.
RegistryRemove(RegistryRemoveRequest) → RegistryRemoveResponse
Remove a project from the registry by name.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Project name to remove |
Errors: NOT_FOUND if no project with that name exists; INTERNAL with the
stable message failed to persist registry state when saving fails.
CLI: foundry registry remove <name> routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to bypass the daemon and
mutate the registry file directly. Without --offline, an unreachable daemon
returns an error and leaves the client-side registry file unchanged. The online
path mutates daemon-owned state only and does not create
FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL
with a stable failed to persist registry state message and leaves both the
daemon's in-memory registry and its on-disk registry bytes unchanged.
RegistryEdit(RegistryEditRequest) → RegistryEditResponse
Apply partial edits to an existing project. Only fields that are non-empty /
non-zero are applied. Use clear_* booleans to explicitly clear optional fields
(e.g. clear_skip = true to un-skip a project).
Request:
| Field | Type | Description |
|---|---|---|
name | string | Project to edit (required) |
path | string | New path (empty → no change) |
stack | string | New stack (empty → no change) |
agent | string | New agent (empty → no change) |
repo | string | New repo slug (empty → no change) |
branch | string | New branch (empty → no change) |
skip | string | New skip reason; non-empty sets it; empty → no change unless clear_skip |
clear_skip | bool | Remove the skip flag |
iterate | bool | Set iterate to true (use clear_iterate to set false) |
clear_iterate | bool | Set iterate to false |
maintain | bool | Set maintain to true |
clear_maintain | bool | Set maintain to false |
push | bool | Set push to true |
clear_push | bool | Set push to false |
audit | bool | Set audit to true |
clear_audit | bool | Set audit to false |
release | bool | Set release to true |
clear_release | bool | Set release to false |
install_command | string | Set a shell-command install |
install_brew | string | Set a Homebrew formula install |
clear_install | bool | Remove the install config |
notes | string | Set notes (empty string + clear_notes = false → no change) |
clear_notes | bool | Remove notes |
timeout_secs | uint64 | Set timeout (0 → no change unless clear_timeout) |
clear_timeout | bool | Revert timeout to the daemon default |
update_policy | string | Set patch, minor or major (empty → no change) |
installs_skill | string | Set true, false, or a skill-install command (empty → no change; blank → INVALID_ARGUMENT) |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The updated project entry |
Errors: NOT_FOUND; INVALID_ARGUMENT for conflicting install fields or
unknown stack; INTERNAL with the stable message
failed to persist registry state when saving fails.
CLI: foundry registry edit <name> ... routes through this RPC by default
and therefore requires a reachable daemon. Pass --offline to bypass the daemon
and mutate the registry file directly. Without --offline, an unreachable
daemon returns an error and leaves the client-side registry file unchanged. The
online path mutates daemon-owned state only and does not create
FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL
with a stable failed to persist registry state message and leaves both the
daemon's in-memory registry and its on-disk registry bytes unchanged.
SentinelList(SentinelListRequest) → SentinelListResponse
List every daemon-owned sentinel from the in-memory scheduler control-plane
state held by foundryd.
Request:
This message has no fields.
Response:
| Field | Type | Description |
|---|---|---|
sentinels | repeated Sentinel | Every daemon-owned sentinel entry in scheduler evaluation order |
Each Sentinel carries the exact scheduler contract the CLI needs to render:
name, cron, emit_event_type, emit_project, emit_throttle,
emit_payload_json, and enabled.
Errors: None at the RPC layer; the daemon answers from already-loaded sentinel state.
CLI: foundry sentinel list routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to read the sentinel
file directly. Without --offline, an unreachable daemon returns an error and
leaves the client-side sentinel file unchanged. The online path renders the RPC
response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is
absent, the online path leaves it absent.
SentinelShow(SentinelShowRequest) → SentinelShowResponse
Retrieve one exact-name sentinel from the daemon-owned scheduler control-plane state. The name match is exact and does not perform prefix or substring lookup.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to retrieve |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The full daemon-owned sentinel record for that name |
Errors: NOT_FOUND if no exact-name sentinel exists.
CLI: foundry sentinel show <name> routes through this RPC by default and
therefore requires a reachable daemon. Pass --offline to read the sentinel
file directly. Without --offline, an unreachable daemon returns an error and
leaves the client-side sentinel file unchanged. The online path renders the RPC
response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is
absent, the online path leaves it absent.
SentinelEnable(SentinelEnableRequest) → SentinelEnableResponse
Mark one daemon-owned sentinel as enabled, persist the updated sentinel store, and wake the in-process scheduler so the next firing is recomputed immediately.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to enable |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The committed daemon-owned sentinel record after enable |
Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the
daemon cannot persist the sentinel store.
CLI: foundry sentinel enable <name> routes through this RPC by default
and therefore requires a reachable daemon. Pass --offline to bypass the
daemon and mutate the sentinel file directly. Without --offline, an
unreachable daemon returns an error and leaves the client-side sentinel file
unchanged. The online path mutates daemon-owned state only and does not create
FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns
INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does
not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.
SentinelDisable(SentinelDisableRequest) → SentinelDisableResponse
Mark one daemon-owned sentinel as disabled, persist the updated sentinel store, and wake the in-process scheduler so any pending firing is cancelled immediately.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to disable |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The committed daemon-owned sentinel record after disable |
Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the
daemon cannot persist the sentinel store.
CLI: foundry sentinel disable <name> routes through this RPC by default
and therefore requires a reachable daemon. Pass --offline to bypass the
daemon and mutate the sentinel file directly. Without --offline, an
unreachable daemon returns an error and leaves the client-side sentinel file
unchanged. The online path mutates daemon-owned state only and does not create
FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns
INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does
not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.
AddCampaign(AddCampaignRequest) → AddCampaignResponse
Add one campaign definition to the daemon-owned campaign store. The daemon parses the JSON definition, validates the referenced project and context paths against daemon-owned registry state, acquires the exclusive campaign-store lock, and persists the new definition atomically.
Request:
| Field | Type | Description |
|---|---|---|
definition_json | string | Full campaign definition JSON exactly as accepted by foundry campaign add |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full durable definition that was persisted |
Errors: INVALID_ARGUMENT when the JSON is invalid or the definition is
structurally invalid; FAILED_PRECONDITION when the definition references an
unknown registered project or invalid context artifact; ALREADY_EXISTS when a
campaign with the same name already exists; INTERNAL when the store cannot be
saved. On INTERNAL, the daemon leaves the on-disk campaign store unchanged.
CLI: foundry campaign add <definition.json> routes through this RPC by
default. The online CLI renders the returned CampaignDetail directly and does
not re-read FOUNDRY_CAMPAIGNS_PATH. Pass --offline only when you
intentionally need direct-file recovery while the daemon is stopped.
ListCampaigns(ListCampaignsRequest) → ListCampaignsResponse
List the durable campaign inventory from the daemon's configured campaign store. This is a read-only query: it loads the store at request time, returns records in deterministic campaign-name order, and exposes summary/status fields only.
Request:
| Field | Type | Description |
|---|---|---|
project | string | Optional exact project-name filter; empty string returns all campaigns |
Response:
| Field | Type | Description |
|---|---|---|
campaigns | repeated Campaign | Durable inventory records sorted by campaign name |
An escalated campaign carries open_escalation, read the same way as
GetCampaign reads it.
Errors: FAILED_PRECONDITION when the campaign store is malformed;
INTERNAL when the campaign store is unreadable, or when an escalated campaign
needs the event-log fallback and the event log cannot be read. Missing or empty
stores return an empty list.
PauseCampaign(PauseCampaignRequest) → PauseCampaignResponse
Pause a campaign. The operation is idempotent on the status field — pausing an
already-paused campaign is not an error. The daemon holds an exclusive lock on
the campaign store for the duration of the write, so a concurrent advance
formation cannot interleave with a pause.
Any pending_run_result that was recorded before the pause is explicitly
preserved: the operation never clears or overwrites it. The result remains
available for the next manual advance after a subsequent resume.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to pause |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after the pause is applied |
Errors: NOT_FOUND when no campaign with the given name exists;
FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the
campaign store is unreadable or when persistence fails. On save failure, the
daemon leaves the persisted campaign store unchanged.
CLI: foundry campaign pause <name> routes through this RPC when the daemon
is reachable. Pass --offline to bypass the daemon and mutate the store file
directly (useful when foundryd is not running). Without --offline, an
unreachable daemon is an error and the client-side campaigns path is left
untouched.
ResumeCampaign(ResumeCampaignRequest) → ResumeCampaignResponse
Resume a paused or escalated campaign, optionally extending its cycle
budget. The daemon holds an exclusive lock on the campaign store for the
duration of the write.
pending_run_result is explicitly preserved: the operation never clears or
overwrites it. The result remains available for the next manual advance after
resume.
Accepted statuses: paused and escalated. Budget-only escalations (where
the engine stopped because the cycle limit was reached) may be resumed with this
RPC without recording an owner-decision entry. Use DecideCampaign when the
escalation contains a human judgment question that requires a policy record.
Exhausted budget guard: when add_cycles == 0 and
cycles_completed >= max_cycles, the RPC returns FAILED_PRECONDITION. Pass a
positive add_cycles to explicitly authorize more work; the engine never
silently reactivates an exhausted campaign.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to resume |
add_cycles | uint64 | Additional cycles to add to max_cycles before resuming (0 = no extension) |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after the resume is applied |
Errors: NOT_FOUND when no campaign with the given name exists;
FAILED_PRECONDITION when the campaign is not paused or escalated, lacks
authorized_by, the budget is exhausted and add_cycles == 0, add_cycles
would overflow max_cycles, or the campaign store is malformed; INTERNAL when
the campaign store is unreadable or persistence fails. On save failure, the
daemon leaves the persisted campaign store unchanged.
CLI: foundry campaign resume <name> routes through this RPC when the
daemon is reachable. Pass --offline to bypass the daemon and mutate the store
file directly (useful when foundryd is not running). Without --offline, an
unreachable daemon is an error and the client-side campaigns path is left
untouched. The rendered output is built from the
ResumeCampaignResponse.campaign detail — the CLI never re-reads the store file
on the online path.
DecideCampaign(DecideCampaignRequest) → DecideCampaignResponse
Record a binding owner decision on an active, paused or escalated
campaign. The daemon holds the exclusive campaign-store lock for the full
mutation and appends one durable owner decision record. Every later formation
prompt carries it as binding policy.
The persisted owner decision carries the decision text, the campaign's current
authorized_by identity, and the daemon timestamp. Existing counters and any
stored pending_run_result are preserved. No event is emitted; the response
carries the resulting status.
Active or paused: the decision is recorded and nothing else changes. The
status, max_cycles and open_escalation stay as they were. A spent budget does
not block it. A positive add_cycles returns FAILED_PRECONDITION and records
nothing: budget extension through this RPC answers an escalation, and a paused
campaign takes more cycles through ResumeCampaign. A decision recorded while
the campaign forms makes that formation form again from the changed record.
Escalated: the decision answers the escalation. The daemon adds add_cycles
to max_cycles (checked addition), clears open_escalation and sets active,
all in one transition. When cycles_completed >= max_cycles, the request must
carry a positive add_cycles; with add_cycles == 0 the RPC returns
FAILED_PRECONDITION and records nothing, because an active campaign with no
cycle left would escalate on budget at its next advance, and ResumeCampaign
does not accept an active campaign.
Retries: on an active or paused campaign, a decision whose trimmed text
and owner equal the latest recorded decision returns FAILED_PRECONDITION
("campaign 'active, so its retry meets this check before the add_cycles check:
the decision is not recorded twice and add_cycles is not applied twice. An
escalated campaign accepts the same words, because it stopped on a new
question after that decision.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to update |
decision | string | Non-empty owner decision text to record |
add_cycles | uint64 | Cycles to add to max_cycles with the decision (0 = none; escalated campaigns only; required on a spent escalated budget) |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after the decision is applied |
Errors: NOT_FOUND when no campaign with the given name exists;
INVALID_ARGUMENT when decision is empty after trimming;
FAILED_PRECONDITION when the campaign is staged, completed or
cancelled, lacks authorized_by, repeats its latest decision, is active or
paused and add_cycles > 0, is escalated with a spent budget and
add_cycles == 0, add_cycles would overflow max_cycles, or the campaign
store is malformed; INTERNAL when the
campaign store is unreadable or persistence fails. On save failure, the daemon
leaves the persisted campaign store unchanged.
CLI: foundry campaign decide <name> --decision "<text>" [--add-cycles <n>] routes through
this RPC by default and therefore requires a reachable daemon. Pass --offline
to bypass the daemon and mutate the store file directly. Without --offline, an
unreachable daemon returns an error and leaves the client-side campaigns path
unchanged.
CompleteCampaign(CompleteCampaignRequest) → CompleteCampaignResponse
Mark an authorized campaign complete from outside the formation loop. The
request requires a non-empty reason and an existing authorized_by owner. The
daemon stores the reason as an append-only owner record, clears any pending run
result, changes the status to completed, and emits the normal
CampaignCompleted event for terminal observers. Calling it on an already
completed campaign is idempotent.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to complete |
reason | string | Evidence-backed owner reason for external completion |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after completion is applied |
Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown
campaign, and FAILED_PRECONDITION when the campaign has no authorizing owner
or the campaign store is malformed; INTERNAL when the campaign store is
unreadable or persistence fails. On save failure, the daemon leaves the
persisted campaign store unchanged and does not emit CampaignCompleted.
CLI: foundry campaign complete <name> --reason "<text>" routes through
this RPC by default. The online CLI renders the returned CampaignDetail
directly, never re-reads FOUNDRY_CAMPAIGNS_PATH, and leaves the client-side
path untouched if the daemon is unreachable. Pass --offline only for
direct-file recovery while the daemon is stopped.
CancelCampaign(CancelCampaignRequest) → CancelCampaignResponse
Stop a campaign permanently before its done evidence was met. Distinct from
CompleteCampaign because completed is an evidence claim and cancellation
records the opposite. The daemon changes the status to cancelled, clears any
pending run result, stores the reason as an append-only owner record when the
campaign has an owner, and emits CampaignCancelled for terminal observers.
Unlike CompleteCampaign, this does not require authorized_by — an
unauthorized campaign has no other reachable terminal state.
With terminate_now, the daemon aborts the in-flight workflow before touching
the store. Aborting it kills the running agent process. A formation holds the
campaign store lock only in its short read and write sections, never while its
agent runs; if the abort lands inside one, dropping the task releases the lock.
The abort is awaited, so the subsequent store acquisition does not wait on the
aborted task. discard_work then selects how the orphaned worktree is disposed
of by the DisposeCampaignWork block.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to cancel |
reason | string | Owner reason for abandoning the mission |
terminate_now | bool | Abort the in-flight workflow, killing the running agent process |
discard_work | bool | Throw the terminated cycle's uncommitted work away |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after cancellation |
event_id | string | The emitted CampaignCancelled event; empty when the call was a no-op |
Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown
campaign, FAILED_PRECONDITION when the campaign is already completed or the
store is malformed, and INTERNAL when the store is unreadable or persistence
fails. Cancelling an already-cancelled campaign is idempotent: it returns the
current detail with an empty event_id and emits no second event.
CLI: foundry campaign cancel <name> --reason "<text>" [--now] [--discard-work] routes through this RPC by default. --discard-work requires
--now. --offline uses a graceful-only direct-file path that emits no event;
--offline --now is refused rather than downgraded.
AdvanceCampaign(AdvanceCampaignRequest) → AdvanceCampaignResponse
Dispatch one manual campaign-advance workflow for an active or staged
campaign. Under the exclusive campaign-store lock the daemon validates the
current status, checks for campaign work already in flight, emits
CampaignAdvanceRequested through spawn_workflow (which records the workflow
in the tracker before it returns), then releases the lock and returns. A
formation never holds that lock while its agent runs, so the call returns
promptly whatever other campaigns are doing.
An advance never duplicates work in flight. When the campaign already has, in
the work-item ledger, an item submitted, queued, held or running (or still
holding its slot) that is one of its cycles (source campaign:<name>) or an
advance of it (a CampaignAdvanceRequested root naming it), or a tracked
advance or cycle workflow not yet admitted to the ledger, the daemon
dispatches nothing and returns in_flight instead of an event_id. Because
the check and the dispatch happen under the same lock, two simultaneous
advances for one campaign dispatch exactly one workflow. The automatic advance
after a cycle completes is not an RPC and is unaffected.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to advance |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Current campaign detail at dispatch time |
event_id | string | Root event ID of the dispatched CampaignAdvanceRequested workflow; empty with in_flight |
in_flight | CampaignWorkInFlight | Set when the request was a no-op because campaign work was already in flight |
CampaignWorkInFlight carries kind (advance or cycle), work_item_id
(empty for an accepted advance not yet in the ledger), state (submitted,
queued, held or running) and the root event_id.
Errors: NOT_FOUND when the name is absent; FAILED_PRECONDITION when the
campaign is paused, escalated, completed or cancelled, or the
work-item ledger is malformed; INTERNAL when the campaign store or the ledger
is unreadable. Rejected advances and no-op advances do not mutate either store.
CLI: foundry campaign advance <name> routes through this RPC by default.
The online CLI prints the returned root event_id, watches the daemon-owned
workflow stream for that advance's own trace only, stops at its
campaign_advance_completed (a decision, or stood_down when the campaign
was stopped first; or its work item's settlement when the advance failed
without one), and renders the daemon trace for that event.
For a no-op it prints one line naming the in-flight work and the command to
inspect it, and exits 0 without watching. It does not re-read
FOUNDRY_CAMPAIGNS_PATH.
GetCampaign(GetCampaignRequest) → GetCampaignResponse
Retrieve the complete durable definition of one campaign by exact name. Unlike
ListCampaigns, this RPC returns the full detail record including
intent_refs, context_paths, done_evidence (with the Gate/Review type
distinction preserved), escalation rules, and all runtime status fields.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to look up |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full durable definition of the campaign |
For an escalated campaign, open_escalation says why it waits on its owner.
The daemon reads it from the campaign record, which formation writes when it
escalates the campaign. A campaign that escalated before the record existed has
none: the daemon then recovers the reason from the newest campaign_escalated
event naming the campaign (every monthly file under FOUNDRY_EVENTS_DIR) and
sets from_event_log. A recovered record is never written back to the store.
Errors: NOT_FOUND when no campaign with the given name exists in the store
(even when other campaigns are present); FAILED_PRECONDITION when the campaign
store is malformed; INTERNAL when the campaign store is unreadable, or when
the event-log fallback cannot read the event log.
CLI: foundry campaign show <name> routes through this RPC by default and
renders the returned CampaignDetail directly, without re-reading
FOUNDRY_CAMPAIGNS_PATH.
ListWorkItems(ListWorkItemsRequest) → ListWorkItemsResponse
List records from the daemon-owned work-item ledger
(FOUNDRY_WORK_ITEMS_PATH). The store is loaded from disk on every call and
nothing is cached between requests, so a caller always sees the ledger as it
stands. This RPC never writes and never takes the ledger write gate.
Request:
| Field | Type | Description |
|---|---|---|
project | string | Exact-match project filter. Empty means every project; alpha does not match alpha-2 |
state | string | Exact-match state filter, given as the item state's serialized tag. Empty means every state |
Response:
| Field | Type | Description |
|---|---|---|
items | repeated WorkItem | The selected records, in order |
Ordering is deterministic and independent of the store's contents order and insertion order:
runningitems, bystarted_atascendingsubmitted,queuedandhelditems, bysubmitted_atascending- Open items (
preserved,needs_decision,failed), bysettled_atdescending - Terminal items (
landed,cancelled), bysettled_atdescending
Ties within a group break by id ascending. A state filter returns the matching
items in the same relative order as the unfiltered list.
Errors: INVALID_ARGUMENT when state is a non-empty tag that is not one
of submitted, queued, held, running, landed, preserved,
needs_decision, failed, cancelled; FAILED_PRECONDITION when the ledger contains malformed
JSON; INTERNAL when the ledger path is unreadable. A missing ledger file, or
one holding {"version":1,"items":[]}, is an empty list rather than an error.
CLI: online callers render this response directly and never read the
client-side ledger file; if FOUNDRY_WORK_ITEMS_PATH is absent, the online path
leaves it absent.
GetWorkItem(GetWorkItemRequest) → GetWorkItemResponse
Retrieve one work-item ledger record by exact id. The store is loaded from disk on every call and nothing is cached between requests. This RPC never writes and never takes the ledger write gate.
Request:
| Field | Type | Description |
|---|---|---|
id | string | Exact work-item id to look up |
Response:
| Field | Type | Description |
|---|---|---|
item | WorkItem | The full durable ledger record |
Errors: NOT_FOUND when no item with that id exists, including when the
ledger holds other items and when the ledger file is missing or empty;
FAILED_PRECONDITION when the ledger contains malformed JSON; INTERNAL when
the ledger path is unreadable.
CLI: online callers render this response directly and never read the
client-side ledger file; if FOUNDRY_WORK_ITEMS_PATH is absent, the online path
leaves it absent.
CloseWorkItem and CancelWorkItem
Both return an item: WorkItem after owner-directed settlement to cancelled.
CloseWorkItemRequest carries id, a nonblank reason, and nonblank
operator_origin; it accepts only preserved, needs_decision, failed.
CancelWorkItemRequest carries id and nonblank operator_origin; it accepts
only submitted, queued, held and uses cancelled by operator as the reason.
Validation returns INVALID_ARGUMENT, unknown exact ids NOT_FOUND, refused
states and malformed ledgers FAILED_PRECONDITION, and I/O faults INTERNAL.
The daemon reloads and atomically replaces the ledger under the shared write
gate before emitting work_item_cancelled on Watch and in the durable event
log. Failed saves emit nothing. Neither RPC dispatches, aborts a workflow nor
disposes of work. The original identity, origin, objective, trace and disposition
remain intact. operator_action records command, operator origin, previous
state, previous reason and optional previous settlement timestamp. Earlier
lifecycle events remain unchanged. CLI mutations reject --offline.
HoldWorkItem and ReleaseWorkItem
Both carry id and nonblank operator_origin and return an item: WorkItem.
HoldWorkItem accepts only a queued item and moves it to held with the
reason held by operator; the scheduler ignores it until released.
ReleaseWorkItem accepts a held item, or one in needs_decision because a
dependency settled some way other than landed, and returns it to queued:
the settled dependencies are dropped from depends_on, and the recorded
reason is the scheduler's own verdict for the item as it now stands. Validation,
error codes, write-gate discipline and operator_action follow CloseWorkItem;
the emitted event is work_item_held or work_item_released, on Watch and in
the durable log, after the ledger is saved. Neither dispatches or aborts work.
GetPacing(GetPacingRequest) → GetPacingResponse
Return the pacing stage as it stands: the host running cap, how many items run, the paused lanes, every running item and every waiting item with its reason. Loaded from the ledger and the pacing files on every call; never writes and never takes the ledger write gate. Running items are listed oldest start first; waiting items in the scheduler's priority order, then held items.
Response: pacing: PacingStatus.
PausePacing and ResumePacing
Both carry lanes (lane tags interactive, campaign, maintenance, or
all; empty means every lane) and nonblank operator_origin, and return
pacing: PacingStatus after the change. PausePacing stops new starts in the
named lanes; running items are untouched. ResumePacing allows starts again
and the next scheduler tick starts the next eligible item. The pause state is
saved to FOUNDRY_PACING_STATE_PATH through an atomic rename and is in force
after a restart. An unknown lane is INVALID_ARGUMENT; a failed save is
INTERNAL and emits nothing. Each emits pacing_paused or pacing_resumed
for the project system, durable and on Watch, even when the named lanes were
already in that state.
ListWorkItemEvents(ListWorkItemEventsRequest) → ListWorkItemEventsResponse
List one work item's work_item_* events (work_item_submitted,
work_item_started, work_item_settled, work_item_cancelled,
work_item_held, work_item_released) from the
durable event log under FOUNDRY_EVENTS_DIR (one YYYY-MM.jsonl file per
month). Additive to GetWorkItem: a caller that never sends this request sees
exactly the GetWorkItem and ListWorkItems behaviour it saw before. The
ledger and the event log are loaded from disk on every call and nothing is
cached between requests. This RPC never writes and never takes the ledger write
gate.
Request:
| Field | Type | Description |
|---|---|---|
id | string | Exact work-item id whose events to list |
Response:
| Field | Type | Description |
|---|---|---|
events | repeated WorkItemEvent | The item's events, in chronological order |
Selection and order: events are selected by exact payload item_id — never
by trace id and never by project, since the items of one maintenance run share
both — from every monthly file regardless of age, and returned by occurred_at
ascending, ties in log order (file name, then line).
Errors: NOT_FOUND when no item with that id is in the ledger;
FAILED_PRECONDITION when the ledger contains malformed JSON; INTERNAL when
the ledger path is unreadable, or when an existing events directory or log file
cannot be read — never an empty list. A known item with no events, or a missing
events directory, is an empty list. A malformed line in the log (not JSON, or two
events glued onto one line) is skipped with a warning in the daemon log, and the
item's well-formed events on other lines are still returned.
CLI: foundry queue show <id> renders this after GetWorkItem. Online
callers never read the client-side ledger or events files; if
FOUNDRY_WORK_ITEMS_PATH or FOUNDRY_EVENTS_DIR is absent, the online path
leaves it absent.
History(HistoryRequest) → HistoryResponse
List durable trace history from the daemon-owned trace store.
Request:
| Field | Type | Description |
|---|---|---|
date | string | Exact day in YYYY-MM-DD format, or empty to request recent history |
project | string | Exact project filter, or empty for all projects |
recent_days | uint32 | Number of recent days to return when date is empty; online CLI uses 7 |
Response:
| Field | Type | Description |
|---|---|---|
days | repeated HistoryDay | Matching days, newest day first |
Each HistoryDay carries a date plus repeated HistoryTrace. HistoryTrace
contains event_id, event_type, project, success, total_duration_ms,
and trace_id.
Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/
and survive daemon restarts. The daemon reads that store directly when serving
history, preserving durable retention semantics even after in-memory cache
entries expire.
CLI: foundry history routes through this RPC by default, renders the
daemon response directly, and does not read or create a client-side
FOUNDRY_TRACES_DIR unless --offline is explicit.
Trace(TraceRequest) → TraceResponse
Retrieve the trace of a completed event chain. Returns all events produced during processing and a record of each block execution.
Request:
| Field | Type | Description |
|---|---|---|
event_id | string | Root event ID to look up |
Response:
| Field | Type | Description |
|---|---|---|
found | bool | Whether a trace was found for the given event ID |
events | repeated TraceEvent | All events in the chain |
block_executions | repeated TraceBlockExecution | Record of each block execution |
Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/ and
survive daemon restarts. The Trace RPC checks the in-memory store first (for
recently completed chains) and falls back to disk for older traces.
CLI: foundry trace <event-id> routes through this RPC by default. When no
trace is found, the CLI prints No trace found for <event-id> (expired or unknown).
Span(SpanRequest) → SpanResponse
Retrieve every event and block execution that belongs to one span.
Request:
| Field | Type | Description |
|---|---|---|
span_id | string | Exact span ID to look up |
Response:
| Field | Type | Description |
|---|---|---|
found | bool | Whether a span was found for the given span ID |
events | repeated TraceEvent | Events whose span_id matches the requested span |
block_executions | repeated TraceBlockExecution | Blocks whose own span or parent span matches |
trace_id | string | Owning trace ID, including for block-only spans |
total_duration_ms | uint64 | Sum of returned block durations |
Span is an in-memory lookup keyed by the daemon's span index. It is intended
for live drill-down and status filtering rather than durable offline browsing.
If the span is unknown, the response is found = false with empty collections
and an empty trace_id.
Messages
WorkflowStatus
| Field | Type | Description |
|---|---|---|
workflow_id | string | Workflow identifier |
workflow_type | string | Workflow type name |
project | string | Target project |
state | string | pending, running, completed, failed |
started_at | string | ISO 8601 timestamp |
completed_at | string | ISO 8601 timestamp (empty if running) |
task_blocks | repeated TaskBlockStatus | Per-block status |
TaskBlockStatus
| Field | Type | Description |
|---|---|---|
name | string | Block name |
state | string | pending, running, completed, skipped, failed |
started_at | string | ISO 8601 timestamp |
completed_at | string | ISO 8601 timestamp |
throttled | bool | True if emission was suppressed by throttle |
TraceEvent
| Field | Type | Description |
|---|---|---|
event_id | string | Deterministic event identifier |
event_type | string | Event type name |
project | string | Target project |
occurred_at | string | ISO 8601 timestamp |
throttle | Throttle enum | Throttle level for this event |
WorkItem
One durable work-item ledger record, as returned by ListWorkItems and
GetWorkItem. The fields only a settled item has are proto3 optional, so a
caller can tell "not recorded" from a recorded empty string or false.
| Field | Type | Description |
|---|---|---|
id | string | Stable identity minted at submission (wi_ plus 24 hex characters) |
project | string | Registry project name |
objective | string | The task description or campaign objective the work serves |
kind | string | task, campaign_cycle, maintenance, major_upgrade, release, or remediation |
lane | string | interactive, campaign, or maintenance |
origin | string | Opaque submitter text; Foundry never interprets it |
submitted_at | string | ISO 8601 timestamp the item entered the ledger |
started_at | optional string | ISO 8601 timestamp an agent started on it; absent when it never started |
settled_at | optional string | ISO 8601 timestamp it settled; absent while unsettled |
state | string | submitted, queued, held, running, landed, preserved, needs_decision, failed, or cancelled |
reason | string | Why it is in that state, in one line; for a queued item, the scheduler's current verdict |
trace_id | optional string | The workflow trace the item belongs to; absent when it carries none |
verdict | optional string | Settlement: the reviewer's typed verdict tag |
landed_commit | optional string | Settlement: the trunk commit the work landed as; absent unless it landed |
preservation_ref | optional string | Settlement: the durable ref (branch or bundle:<path>) holding unlanded work |
worktree | optional string | Settlement: the isolated worktree the work ran in |
worktree_removed | optional bool | Settlement: whether that worktree was gone by settlement time; absent when the item records no worktree |
depends_on | repeated string | Pacing: the items this one waits on; it starts only once every one has settled landed. Empty when none |
not_before | optional string | Pacing: the earliest time the scheduler may start it (ISO 8601); absent when unset |
WorkItem.operator_action is an optional WorkItemOperatorAction message with
command (close, cancel, resume, hold or release), origin,
previous_state, previous_reason and optional previous_settled_at. Absent
on items without an owner action.
ResumeWorkItemRequest also carries additive depends_on (repeated string;
each must be in the ledger) and not_before (ISO 8601 string; empty means at
once).
PacingStatus and PacingItem
| Field | Type | Description |
|---|---|---|
max_running | uint64 | The host running cap from pacing.json (default 2) |
running | uint64 | How many items run on this host now |
paused_lanes | repeated string | The paused lanes, as lane tags, in lane order |
running_items | repeated PacingItem | Every running item, oldest start first |
waiting_items | repeated PacingItem | Queued items in the scheduler's priority order, then held |
A PacingItem carries id, project, repository (the key the
per-repository rule uses), kind, lane, state, reason and since
(started_at for a running item, submitted_at for a waiting one).
WorkItemEvent
One work_item_* lifecycle event read back from the durable event log, as
returned by ListWorkItemEvents.
| Field | Type | Description |
|---|---|---|
id | string | The event's own id |
event_type | string | work_item_submitted, work_item_started, work_item_settled, work_item_cancelled, work_item_held, or work_item_released |
occurred_at | string | ISO 8601 timestamp the event occurred |
state | string | The item's state as of this event (same vocabulary as WorkItem.state) |
reason | string | Why it was in that state, in one line |
trace_id | optional string | The workflow trace the event was emitted on; absent when it carries none |
Campaign
Summary-only wire form returned by ListCampaigns. Intentionally omits
intent_refs, context_paths, done_evidence, and escalation — use
GetCampaign to retrieve those fields.
| Field | Type | Description |
|---|---|---|
name | string | Campaign name |
project | string | Registered project name |
mission | string | Campaign mission statement |
status | string | Durable status: staged, active, paused, escalated, or completed |
cycles_completed | uint64 | Number of dispatched task cycles |
cycles_landed | uint64 | Number of task results whose work reached trunk |
max_cycles | uint64 | Configured campaign cycle budget |
authorized_by | string | Owner authorization identity, or empty when absent |
agent_provider | string | Preferred agent provider, or empty when absent |
last_run_event_id | string | Most recent campaign-run event ID, or empty when absent |
open_escalation | CampaignEscalation | Why the campaign waits on its owner; set only while escalated, absent when no reason was ever recorded |
CampaignEscalation
Why an escalated campaign waits on its owner.
| Field | Type | Description |
|---|---|---|
reason | string | The reason formation recorded, as the campaign_escalated event carries it |
escalated_at | string | RFC 3339 time of the escalation |
cycles_completed | uint64 | Dispatched cycles at the escalation |
cycles_landed | uint64 | Cycles whose work had landed at the escalation |
question | optional string | The task agent's question, when a task stopped with blocked_on_decision |
choices | repeated string | The choices the task agent offered with its question |
from_event_log | bool | The record was recovered from the event log; the escalation predates the record |
CampaignDetail
Full durable definition of a campaign, as returned by GetCampaign. Carries all
fields in Campaign plus the definition-time fields that the summary form
omits.
| Field | Type | Description |
|---|---|---|
name | string | Campaign name |
project | string | Registered project name |
mission | string | Campaign mission statement |
status | string | Durable status: staged, active, paused, escalated, or completed |
cycles_completed | uint64 | Number of dispatched task cycles |
cycles_landed | uint64 | Number of task results whose work reached trunk |
max_cycles | uint64 | Configured campaign cycle budget |
authorized_by | string | Owner authorization identity, or empty when absent |
agent_provider | string | Preferred agent provider, or empty when absent |
last_run_event_id | string | Most recent campaign-run event ID, or empty when absent |
intent_refs | repeated string | Intent reference labels that anchor the mission |
context_paths | repeated string | Paths to context documents for the campaign |
done_evidence | repeated DoneEvidence | Completion criteria with Gate/Review type distinction |
escalation | repeated string | Human-readable escalation instructions |
owner_decisions | repeated OwnerDecision | Append-only owner policy decisions recorded after escalations |
open_escalation | CampaignEscalation | Why the campaign waits on its owner; see Campaign.open_escalation |
OwnerDecision
One recorded owner decision attached to a campaign after an escalation. These records are append-only and are threaded back into future campaign formation prompts as binding context.
| Field | Type | Description |
|---|---|---|
decision | string | Owner-authored decision text |
authorized_by | string | Owner identity copied from the campaign at record time |
decided_at | string | RFC 3339 timestamp recorded by the daemon |
DoneEvidence
One completion-evidence item. The kind field distinguishes the two variants.
Gate variant (kind = "gate"):
| Field | Type | Description |
|---|---|---|
kind | string | "gate" |
command | string | Shell command to evaluate |
required | bool | Whether this gate is required for completion |
statement | string | Empty for Gate items |
artifacts | repeated string | Repository-relative paths that must exist before the gate can pass |
Review variant (kind = "review"):
| Field | Type | Description |
|---|---|---|
kind | string | "review" |
command | string | Empty for Review items |
required | bool | false for Review items |
statement | string | Human-readable completion statement |
artifacts | repeated string | Empty for Review items |
TraceBlockExecution
| Field | Type | Description |
|---|---|---|
block_name | string | Name of the block that executed |
trigger_event_id | string | Event ID that triggered this block |
success | bool | Whether the block succeeded |
summary | string | Human-readable summary of the result |
emitted_event_ids | repeated string | IDs of events emitted by this block |
duration_ms | uint64 | Wall-clock milliseconds for this block execution (including retries) |
raw_output | string | Combined stdout+stderr from any shell command run by this block |
exit_code | int32 | Exit code from any shell command run by this block |
trigger_payload_json | string | JSON payload of the event that triggered this block |
emitted_payload_jsons | repeated string | JSON payloads of events emitted by this block |
audit_artifacts | repeated string | Paths to audit artefact files produced by this block |
Throttle (enum)
| Value | Number | Description |
|---|---|---|
THROTTLE_FULL | 0 | All blocks emit |
THROTTLE_AUDIT_ONLY | 1 | Observers emit, mutators suppress |
THROTTLE_DRY_RUN | 2 | Read-only, no side effects |