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 compile

Instruction 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-secret

Slack 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 as deep: {runner: claude, model: claude-opus-5-5}. The step runs in an agent with a workspace, like default.
  • A model profile has only a model, written <provider>/<model> with provider anthropic or openai. 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's returns shape 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.
  • review is 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: 45m

message 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.