Write a workflow
A workflow is an outcomeci.workflow/v1 file that describes a short list of
steps. Each step is a function over three things the file declares up front:
the secrets it may use, the APIs those secrets unlock, and the agents that do
the reasoning.
A file holds one workflow. oci init names it outcome.yml; any other name
works with --config. Validate and compile it before running:
oci validate
oci workflow compileInstruction files a step names with reason: live under
.outcomeci/instructions/. Compilation hashes them into the workflow
revision, so changing an instruction changes the revision.
A complete workflow
A Sentry alert is triaged in #sentry. A thumbs-up reaction on the triage
message approves a fix, and the result is announced in the alert's thread.
apiVersion: outcomeci.workflow/v1
name: sentry-to-github-pr
trigger: webhook
secrets:
slack: vault:slack/bot-token
github: vault:outcomeci-github
apis:
slack: {uses: slack, auth: secrets.slack}
github: {uses: github, auth: secrets.github}
reasoning:
default: {runner: codex, model: gpt-5.5}
fallback: [{runner: claude, model: claude-opus-5-5}]
steps:
- triage:
reason: triage-and-notify.md
from: trigger
can:
- github.read
- slack.post: {channel: sentry}
returns:
decision: enum[fix, no_op]
issue: {title, message, culprit, level, project, url}
repo?: {owner, name}
reason: string
- approve:
when: triage.decision == "fix"
await:
slack.reaction: {message: triage.calls.slack.post, emoji: "+1"}
timeout: 45m
- fix:
when: triage.decision == "fix"
reason: fix-and-open-pr.md
with: [trigger, triage]
can:
- github.write: {repo: triage.repo}
policy: "One new branch from the default branch, one PR, only files the fix needs. No force-push."
returns:
pr?: {url, number: int, branch}
reason?: string
- announce:
with: fix
reason: >
Reply in the alert's thread with the PR link and a one-line summary of
the fix, or, if there is no PR, the reason.
can:
- slack.post: {channel: sentry, thread_ts: triage.calls.slack.post.ts}The top-level fields are apiVersion, name, trigger, secrets, apis,
reasoning, and steps. Any other field fails validation. name defaults to
the part of the file name before the first dot.
Trigger
trigger names what starts a run:
| Value | Starts a run when |
|---|---|
manual |
Someone starts the workflow by hand, such as with oci workflow run |
email |
Mail reaches the workflow's address |
webhook |
A request reaches the workflow's webhook URL, queued for asynchronous delivery |
{type: cron, expression, timezone} |
The cron expression matches in the IANA time zone |
{webhook: {uses, auth, events}} |
A provider-signed event arrives and the provider's receiver verifies it |
A run's trigger input depends on the trigger. A manual run's trigger is
the JSON payload it was started with. A plain webhook run's trigger is the
request envelope: method, query, headers, and the body as body_base64.
A cron run's trigger records the schedule and the time it fired.
A cron schedule fires at most once every five minutes and leaves either
day-of-month or day-of-week as *.
trigger: {type: cron, expression: "0 9 * * 1-5", timezone: America/New_York}Receive provider webhooks
With webhook: {uses, auth, events}, the provider's receiver checks each
request before anything is queued. It verifies the signature with the signing
secret that auth names, answers the provider's URL verification handshake,
and starts a run only for the listed events. The run's trigger input is
the provider's normalized event rather than the raw request.
trigger:
webhook: {uses: slack, auth: secrets.slack_signing, events: [mention, dm]}
secrets:
slack_signing: vault:slack/signing-secretSlack is the provider with a receiver. Its events are mention, a top-level
message that mentions the app in a channel it is in, and dm, a top-level
direct message to the app. Each run's trigger carries the message's channel,
user, text, and ts. The signing secret is used only to verify requests
and is never available to a step.
Secrets
secrets maps a name to a vault: reference. A secret is usable only through
an API binding or a webhook receiver; no step reads a secret directly.
The same reference resolves from the local Vault when you run in the container and from the workspace Vault in a cloud run, so the file does not change between the two. In the workspace Vault, grant the workflow access to each secret it references. See Vault.
APIs
apis binds a secret to one of the available providers, slack or
github:
apis:
slack: {uses: slack, auth: secrets.slack}
github: {uses: github, auth: secrets.github}uses names the provider and auth names the declared secret. auth never
says how to authenticate: the Vault credential behind the secret does, as a
token, an OAuth app, a GitHub App installation, or any other kind the provider
accepts. See Vault. The provider
defines the base URL, the credential kinds it accepts, a per-step request
budget, and the operations a step can be granted. Connectors
describes each provider in full:
| Provider | Operation | What it does | Can be scoped by |
|---|---|---|---|
slack |
post |
Posts a message, or a thread reply with thread_ts |
channel, thread_ts |
slack |
thread |
Reads a message's thread | channel |
slack |
file |
Opens a file shared in the conversation, such as a screenshot | channel |
slack |
reactions |
Reads the reactions on a message | channel |
github |
read |
Sends a GET to any path under the GitHub REST API |
repo |
github |
write |
Creates branches, commits files, and opens pull requests | repo |
github.write refuses merges, deletes, moving existing branches, branch
protection, and repository settings or administration, whatever the step
asks for.
slack.file downloads the file into the run's attachments/ directory and
returns its name, type, size, and a local file.path the agent can open.
Files larger than 2 MiB are refused.
Reasoning
reasoning chooses how steps think:
secrets:
anthropic: vault:anthropic/api-key
reasoning:
default: {runner: codex, model: gpt-5.5}
fallback: [{runner: claude, model: claude-opus-5-5}]
light: {model: anthropic/claude-haiku-4-5}
review: {model: anthropic/claude-sonnet-5, key: secrets.anthropic}default accepts runner (codex, claude, or opencode) and model, and
defaults to codex. fallback lists one agent to retry with when the default
runner hits a usage or rate limit.
Any other entry is a named profile, and a step picks one with using: <name>:
- An agent profile has a
runner, such asdeep: {runner: claude, model: claude-opus-5-5}. The step runs in an agent with a workspace, likedefault. - A model profile has only a
model, written<provider>/<model>with provideranthropicoropenai. The step makes direct model calls with no workspace: its granted APIs are offered to the model as tools, each call goes through the same grants and policy review as an agent's, and the step'sreturnsshape is the result the model must give. A model step makes at most 20 calls. Use it for steps that read, decide, and post, such as triage or an announcement; keep an agent for steps that change code. reviewis a model profile that sets the policy reviewer. No step can use it.
A model profile's key: secrets.<name> pays with the provider API key stored
in that Vault secret. Without key, OutcomeCI Cloud's key is used and the
usage is metered to your workspace.
A step can also set its agent inline with using: {runner, model}. A
converse step accepts using too: with a model profile, each turn of the
discussion is one model call.
Steps
steps run from top to bottom. Each entry is - <name>: {...}, and the
fields present decide the step's kind: a step with await waits for a human
signal, a step with converse holds a conversation, and any other step is an
agent step. trigger and calls are reserved and cannot name a step.
Reference earlier results
A step reads only the trigger and the steps above it:
| Reference | Reads |
|---|---|
trigger, trigger.<field> |
The run's trigger payload |
<step> |
Everything the step returned |
<step>.<output> |
One key of the step's returns |
<step>.calls.<api>.<operation> |
The last call of that operation the runtime recorded for the step |
<step>.calls.<alias> |
A call made through a grant named with as |
Agent steps
| Field | Purpose |
|---|---|
reason |
The instructions: inline text, or a .md file name under .outcomeci/instructions/ |
from, with |
References the step reads as inputs |
can |
The operations the step may call, with optional argument rules |
policy |
Rules a grant cannot express, checked by the policy reviewer before each call that changes something |
returns |
The shape of the step's result |
when |
A condition; the step is skipped unless it holds |
for_each |
Runs the step once per item of a list |
using |
A {runner, model} override for this step |
can lists grants. A bare github.read grants the operation with no
restriction. A mapping fixes arguments on every call:
can:
- slack.post: {channel: trigger.channel, thread_ts: trigger.ts}
- slack.post: {channel: build, as: plan_post}
- github.write: {repo: target.repo}An argument is either a literal, such as the channel build, or a reference
to data, such as trigger.channel, so a step can reply wherever a request
came from and nowhere else. The runtime enforces the argument on every call
and fills it in when the agent leaves it out. A repo literal is written as
owner/name. When one operation is granted more than once, name each grant
with as.
returns declares the result with a compact shape:
| Shape | Means |
|---|---|
A bare key, or string |
A string |
int, number, bool, object, any |
That JSON type |
enum[a, b] |
One of those strings |
[shape] |
A list of that shape |
| A mapping | An object; keys ending in ? are optional |
when accepts <ref>, <ref> == <value>, or <ref> != <value>. A step that
reads a skipped step is skipped too.
for_each: <list> as <name> runs the step once per item, each run with its
own grants, such as github.write: {repo: target.repo}. The step's outputs
become lists with one entry per item.
Await steps
An await step waits for one human signal from a declared API:
- approve:
await:
slack.reaction: {message: triage.calls.slack.post, emoji: "+1", by: trigger.user}
timeout: 45mmessage must reference a recorded call. emoji defaults to +1. by
counts only one person's signal. Before it waits, the runtime posts what
approving lets the later steps do. timeout accepts 30s, 45m, 2h, 1d,
or a number of seconds, and defaults to one hour. If the window closes
without the signal, every remaining step is skipped.
Converse steps
A converse step discusses a plan in a thread until the requester approves
it:
- discuss:
with: draft.plan
converse: slack.thread(draft.calls.slack.post)
by: trigger.user
until: converged
max_turns: 12
returns: [plan, status]converse names the thread operation and the recorded message that starts
the thread. with names the one plan under discussion, an earlier step's
output. Each reply gets one fresh agent turn that answers questions and
revises the plan; the runtime posts every plan version itself, so the thread
shows exactly what later steps receive. Files shared in the thread are
available through slack.file.
The step returns the latest plan and a status of converged, capped, or
timed_out. max_turns is between 2 and 100 (default 12), and timeout
defaults to one day. reason replaces the default discussion instructions,
and using picks the agent. Every turn and plan version is kept in the
step's consultation.json.
Discuss, then open one pull request per repository
apiVersion: outcomeci.workflow/v1
name: slack-to-github-pr
trigger:
webhook: {uses: slack, auth: secrets.slack_signing, events: [mention, dm]}
secrets:
slack: vault:slack/bot-token
slack_signing: vault:slack/signing-secret
github: vault:outcomeci-github
apis:
slack: {uses: slack, auth: secrets.slack}
github: {uses: github, auth: secrets.github}
reasoning:
default: {runner: codex, model: gpt-5.5}
fallback: [{runner: claude, model: claude-opus-5-5}]
steps:
- draft:
reason: >
Find every repository named in the request as `owner/repo` in
backticks and confirm each exists. Draft one plan with a section per
repo and post it in the request's thread.
from: trigger
can:
- github.read
- slack.post: {channel: trigger.channel}
- slack.file: {channel: trigger.channel}
returns:
plan: {summary, repos: [{repo: {owner, name}, steps: [string]}]}
- discuss:
with: draft.plan
converse: slack.thread(draft.calls.slack.post)
by: trigger.user
until: converged
max_turns: 12
returns: [plan, status]
- implement:
when: discuss.status == "converged"
for_each: discuss.plan.repos as target
reason: implement-and-open-pr.md
using: {runner: claude, model: claude-opus-5-5}
with: [discuss.plan, target]
can:
- github.write: {repo: target.repo}
policy: "One new branch, one PR, only files this repo's steps name. No force-push."
returns:
pr?: {url, number: int, branch}
reason?: string
- announce:
with: [implement, discuss.plan]
reason: >
Post one line per repository in the plan's thread: its PR link with a
one-line summary, or, where there is no PR, the reason.
can:
- slack.post: {channel: trigger.channel, thread_ts: trigger.ts}Next
Run the workflow in the container, or read how policy review checks each call a step makes.