Single-shot CI with mecatequi
mecatequi is a headless, single-run Mecatl runner for CI. It accepts one
prompt, writes up to three artifacts, and exits. It keeps no session state
between runs and starts no network listeners. It uses the same tools,
permissions, and compaction behavior as mecated, but it has no built-in
knowledge of GitHub or another forge. Your workflow handles issue input,
privilege separation, and pull request creation.
It reads the same operator global MCP profiles as mecated, but never launches
an OAuth browser. Authorize local credentials before the job or inject a
preprovisioned environment credential and restart the job. See
MCP client.
What mecatequi produces
A run emits up to three artifacts, plus an exit code:
| Artifact | Flag | Default | Description |
|---|---|---|---|
| Git diff patch | --out-diff | disabled (empty) | A unified patch that reproduces modified, added, and deleted files. Suitable for git apply. |
| Summary JSON | --out-summary | stdout (-) | Machine-readable run result (Summary, schema version 1). Additive-only contract. |
| JSONL event log | --out-events | disabled (empty) | One redacted session.Event per line. An artifact for forensics, not a rehydration source. |
The Summary JSON
Summary in cmd/mecatequi/run.go is the stable contract publish.sh and any
downstream consumer reads. Fields:
| Field | JSON key | Notes |
|---|---|---|
SchemaVersion | schema_version | Always 1; bumped only on a breaking change |
SessionID | session_id | The session that drove this run |
StopReason | stop_reason | Verbatim session.StopReason string (see below) |
NonEmptyDiff | non_empty_diff | Whether the run left an uncommitted working-tree change |
DiffBytes | diff_bytes | Byte length of the computed patch |
Usage | usage | {input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens} |
FinalText | final_text | Terminal assistant prose |
Error | error | Non-empty only when stop_reason == "error" |
The summary is written indented with a trailing newline. When --out-summary is
- (the default), it goes to stdout, making mecatequi ... | jq .stop_reason
work without any flag.
The patch
gitDiffPatch in cmd/mecatequi/run.go runs two git operations against the
workspace:
git diff --no-ext-diff HEAD— tracked modifications and deletions.git ls-files --others --exclude-standard -zfollowed by agit diff --no-index -- /dev/null <file>hunk for each untracked, non-ignored file.
The second step is load-bearing: git diff HEAD alone silently omits new files,
so a patch built from it would lose everything the agent created. Both
non_empty_diff and diff_bytes reflect the full combined patch — a single new
file makes non_empty_diff: true.
The git environment is scrubbed (internal/adapter/gitenv.Scrub) and every git
call carries --no-ext-diff, so no inherited GIT_* variable or repo-named
external diff driver can influence the output.
Exit codes
Exit codes are coarse. Always read stop_reason and non_empty_diff from the
summary to judge whether real work landed.
| Exit code | Meaning |
|---|---|
0 | Clean terminal: end_turn, no_progress, budget, max_turns, max_tool_calls, max_consecutive_failures, structured_output |
1 | Run failure: model error (stop_reason: error), cancelled run, no-approver cancel-on-ask, --timeout exceeded |
2 | Setup failure: bad flags, missing prompt, app.Build / CreateSession error, non-git or non-top-level workspace, colliding output sinks, write failure |
no_progress, budget, and max_turns all produce exit 0 — the model ran
cleanly but may not have finished. A successful CI step that shows
stop_reason: no_progress and non_empty_diff: false means the agent ran
without errors and left no changes. Treat those two fields as the real signal.
The split-privilege model
mecatequi's CI integration enforces a hard token boundary across three jobs.
Neither the binary nor anything in engine/ or internal/ knows about this
boundary — it is purely a property of the workflow graph.
acknowledge runs first, gated on the same trigger as implement. It holds
only issues:write — no LLM key, no contents write. It posts a single comment
to the issue confirming the run has started. This guarantees a durable
issue-side signal even if everything downstream fails silently.
implement holds contents:read and the LLM key. No write scope, no
id-token. It runs the agent and uploads the patch, summary, and event log as
workflow artifacts. If a prompt-injection attack in the issue text hijacks the
agent, the blast radius is the rotatable LLM key — the job cannot push code,
open a PR, or comment because it holds no token that can.
publish holds the write token (contents:write, pull-requests:write,
issues:write) but runs no agent code. It downloads the implement artifacts
and applies the patch as data (git apply). Its if: condition fires on both
implement success and failure so a failed run still lands an honest terminal
comment instead of silence.
The invariant: the step that can write to GitHub never runs agent code; the step that runs agent code never holds a write token.
This invariant holds across both adoption paths — the reusable workflow (§
Reusable workflow) and the escape-hatch template
(.github/workflows/mecatequi-example.yml).
Prompt trust boundary
| Source | Trust | Handling |
|---|---|---|
| Issue / comment text | Untrusted — attacker-controllable | Extracted via extract-prompt.sh, passed via --prompt-file, fenced by --untrusted-prompt |
| Operator workflow config (posture, flags, model) | Trusted | Set by a maintainer in the workflow; passed as action inputs |
--instructions content | Trusted | Emitted outside the untrusted-prompt fence, never fenced |
--untrusted-prompt wraps the prompt body in the harness's canonical
untrusted-data fence (governance.FenceUntrusted in
engine/governance/fence.go) so the model treats the issue text as data to act
on, not instructions to obey. The cmd invokes that shared helper while
assembling the prompt; no engine-loop or composition behavior is changed for it.
--instructions is the symmetric trusted channel: operator framing (such as
"write your final message as a PR description and self-verify before finishing")
that is emitted outside the fence ahead of the prompt body. The GitHub Action
bakes in a default --instructions value covering both of those; passing an
empty string omits the channel entirely, and the prompt is byte-identical to the
pre-flag string.
Reusable workflow — the recommended adoption path
Runner requirement: the reusable workflow resolves its internal action references with GitHub's
$/self-repository syntax, which needs an Actions runner on 2.336.0 or newer. GitHub-hosted runners are already past this. Self-hosted runners update automatically unless that was disabled; a runner older than 2.336.0 fails the job immediately with an invaliduses:error rather than running anything.
.github/workflows/mecatequi-reusable.yml is an on: workflow_call workflow. A
consuming repo references it with a ~15-line caller:
jobs:
mecatequi:
uses: stacklok/mecatl/.github/workflows/mecatequi-reusable.yml@v0.0.3
secrets:
openrouter-key: ${{ secrets.OPENROUTER_CI_TOKEN }}
publish-app-id: ${{ secrets.RELEASE_APP_ID }}
publish-app-private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
label: ready-for-agent
model: anthropic/claude-sonnet-4.6
default-provider: openrouter
Replace v0.0.3 with the latest released tag. The reusable workflow is the same
three jobs with the same per-job permissions, so the token boundary is preserved
exactly. No vendored scripts, no Mecatl checkout in the consumer — the composite
action builds the binary from its own tagged source tree.
The alternative is the escape-hatch template
(.github/workflows/mecatequi-example.yml), which is a hand-rolled copy of the
full job graph. Use it when you need to customise the job graph: a custom
author-gate job, an extra approval stage, a different trigger.
Trigger patterns
The reusable workflow supports two trigger patterns, both configurable via
workflow_call inputs:
Issue label (the default): a maintainer applies a label (default:
mecatequi) to an issue. The acknowledge → implement → publish graph runs once
against the issue body.
Issue comment mention: a maintainer comments @mecatequi (configurable) on
an issue. The graph runs against the comment body.
Both patterns use the trigger as the author gate on private repos — applying a
label or commenting requires triage/write access, so GitHub's own permission
model decides who can start a run. On public repos, add a dedicated
permission-check job that calls the collaborators/{user}/permission API.
Publish token
The publish job resolves its write token in precedence order:
- GitHub App installation token (form 1, stronger): minted in-job when
publish-app-idandpublish-app-private-keysecrets are present. Scoped to exactly this repo's contents + pull-requests + issues. - Pre-minted token (
publish-tokensecret, form 2). - Standing
GITHUB_TOKENgrant (form 3, zero-config, emits a::warning::nudging you toward form 1 or 2).
Customize the PR description
By default, publish writes a PR body with the agent's summary, changed files,
run link, and Closes #<n>. To customize it without changing the workflow, add
a template at .github/mecatequi/pr-body.md. Use the pr-body-template input
to select a different path.
The publish step always prepends its agent-authored-content warning. Do not
repeat that warning in your template. A custom template also owns its issue
relationship, so include Closes {{issue_ref}} or Refs {{issue_ref}} as
appropriate.
The optional pr-title-template input overrides the default
<ISSUE_TITLE> (#<ISSUE_NUMBER>) title. Use short placeholders such as
{{issue_ref}}, {{issue_title}}, {{stop_reason}}, or {{branch}} in
titles. Prose placeholders such as {{what_agent_did}} produce unwieldy
single-line titles.
Key flags
All flags are defined in cmd/mecatequi/flags.go. Operator MCP profiles are
discovered from the conventional global settings.yaml; repeatable
--permission-config PATH entries select explicit trusted settings with higher
precedence. The binary reads provider credentials from the environment
(OPENAI_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_API_KEY,
OPENCODE_API_KEY) — or, alternatively, an auth.yaml credentials file — via
internal/cliconfig.ProviderFlags. All four command roots share its
API-key/base-URL wiring, but only mecated, embedded mecatui, and mecatequi
accept the experimental Codex OAuth snapshot; mecak8s deliberately rejects
that local-file credential. Never pass secrets as flag values.
Prompt and output
| Flag | Default | Notes |
|---|---|---|
--prompt | — | Literal prompt text. At least one of --prompt or --prompt-file is required. |
--prompt-file | — | Path to a file whose contents are the prompt body. |
--untrusted-prompt | false | Wrap the prompt body in the untrusted-data fence. Set true for issue-text input. |
--instructions | "" | Trusted operator framing emitted outside the fence. The GitHub Action sets a non-empty default (PR-description + self-verify framing). |
--out-summary | - (stdout) | Where to write the Summary JSON. |
--out-diff | "" (disabled) | Where to write the git patch. Opt in with a path. |
--out-events | "" (disabled) | Where to write the JSONL event log. Opt in with a path. |
No two outputs may share a sink. Two writers on one stream interleave and corrupt both.
Engine and run control
| Flag | Default | Notes |
|---|---|---|
--workspace | cwd | Session workspace root. Must be a git repository top level. |
--default-provider | "" | Select a provider by id (openai, openai-codex, openrouter, anthropic, opencode). |
--model | "" | Per-session passthrough model id. Accepts any id the provider serves, including ids newer than the embedded catalog. Prefer this over --default-model for newer models. |
--posture | "" (strict) | Operator posture ladder: strict < trusted < auto < yolo. For an autonomous CI run use --posture auto (allow-all, child injection-defence on). With strict posture and --headless, a main-engine permission ask cancels the run and exits 1. |
--trust-project | false | One-shot workspace trust. Admits BOTH cloned-repo steering and the read-only child shell (vouches for .git). Headless posture never grants trust; trustedWorkspaces: or undrifted remembered trust are equivalent persistent/declarative sources. With no source, auto gives allow-all with neither steering nor child shell. See ADR 0095. |
--headless | true | Default on (inverted from mecated). A single-shot CI run has no human approver; child asks are auto-denied or routed to the opt-in ask reviewer. |
--timeout | 0 (disabled) | Wall-clock bound on the whole run (e.g. 40m). A timeout-cancelled run exits 1 with stop_reason: cancelled. |
--max-run-tokens | 0 (unlimited) | Per-engine cumulative input+output token ceiling. The same ceiling is inherited by the main engine, subagents, Parallel branches, team members, and lead synthesis, but each enforces it only against its own session usage. Child spend is excluded from the parent, so a delegation tree can exceed it. Crossing an engine's ceiling ends that engine cleanly with stop_reason: budget. |
--max-team-tokens | 0 (unlimited) | Separate team-round aggregate token ceiling, not a per-engine run ceiling. When crossed, it prevents new team rounds; the current round and lead synthesis still complete. It does not enforce or report a cross-tree aggregate outside that team. |
--max-turns | 0 (deployment default) | Turn cap for this run. 0 inherits the composition default. |
Provider keys
--default-provider selects a provider; the matching key must be present in the
environment or the run fails with "no LLM provider available". A present
OPENAI_API_KEY auto-enables the OpenAI provider without --default-provider.
For OpenRouter, Anthropic, or OpenCode Go, set the respective key and pass
--default-provider openrouter, --default-provider anthropic, or
--default-provider opencode.
Experimental openai-codex is the exception: it has no environment key. Put a
manual ChatGPT Codex OAuth snapshot in owner-only auth.yaml, pass
--api-key-file PATH --default-provider openai-codex, and replace the token
plus restart the job when it expires or is rejected. It is a separate billing
identity from public OpenAI API credit and uses an undocumented private backend;
there is no login or refresh. See
Configure provider credentials.
Telemetry (OPT-IN OTLP push)
mecatequi is single-shot and short-lived, so a Prometheus scrape does not fit it. Instead it PUSHES metrics + traces to an OTLP collector when the flags are set, and flushes before exit. Both endpoints empty (the default) leaves the pipeline off. See ADR 0098 and Key flags.
| Flag | Default | Notes |
|---|---|---|
--otlp-endpoint | "" (off) | OTLP trace collector endpoint; empty disables tracing. |
--otlp-protocol | grpc | OTLP transport for traces (grpc or http). |
--otlp-insecure | false | Skip TLS when dialing the collector (dev only). |
--otlp-metrics-endpoint | "" (off) | OTLP METRICS collector endpoint; empty disables metrics push. |
--otlp-metrics-protocol | grpc | OTLP transport for metrics (grpc or http). |
--otlp-shutdown-timeout | 5s | Bound on the flush at exit (so a dead collector cannot hang the run). |
The flush runs before the diff/summary emit defer unwinds (LIFO) so a single
run's metrics reach the collector before os.Exit. The metric surface reuses
the existing instruments (mecatl.tokens, mecatl.runs, the latency
histograms, …) labelled with the bounded issue-#47 closed role set — no
session/model ids. A mecatl.cost counter is deferred to #192.
Headless posture and permission asks
mecatequi defaults --headless=true. The consequences differ for main-agent
asks versus child asks:
Child asks (subagent / team member / parallel branch): auto-denied by
default. The optional --subagent-ask-reviewer flag inserts a tool-less,
one-turn LLM reviewer that can approve a child ask for that call only. It is
fail-safe: any reviewer error keeps the call denied. It fires only in headless
mode and is deliberately not a config-file key — granting an autonomous approval
capability is an operator deployment decision.
Main-agent asks: with --posture strict (the default) and --headless, a
permission ask from the main engine has no approver. run() in
cmd/mecatequi/run.go detects the first EvPermissionAsk event, calls
r.Cancel(), drains the channel to close, then sets NoApprover=true in the
runOutcome. The run exits 1 with an actionable "re-run with
--posture auto|trusted|yolo or add allow rules" message on stderr. The
StopReason is cancelled, honestly.
The intended CI posture is --posture auto: allow-all for the main agent and
children, child prompt-injection defence on. Use yolo only in a genuinely
disposable, isolated, single-tenant context.
What mecatequi does not support
| Capability | Where to look instead |
|---|---|
| Session continuity across runs | mecated or mecak8s with a durable session store |
| Resuming prior runs | mecated (Service.StartRunContent on a prior session id) |
| Interactive clients (TUI, IDE) | mecated + mecatui |
| Inspecting subagents across invocations | mecated with InspectSubagent and a persisted subagent store |
| Multi-turn conversational bot | Deferred (mecatequi v2, pending cloud-native rehydration seam) |
The stateless design is deliberate. A single-shot run reads its task from the issue, produces artifacts, and exits. The forge (GitHub issue + pull request) is the durable record. There is nothing to re-attach to between runs.
Workspace validation
The workspace must be a git repository AND its top level. A subdirectory would
silently diff an enclosing repo and produce the wrong patch. mecatequi validates
this with validateWorkspaceRepo in cmd/mecatequi/run.go before calling
app.Build, mapping a failure to exit 2 with an actionable error message. The
comparison resolves both the requested workspace and
git rev-parse --show-toplevel to absolute, symlink-evaluated paths, so a
relative or symlinked --workspace still matches correctly.
What's next
- Pick your deployment shape — comparison of mecatequi, mecated, mecak8s, and the embedded engine.
- Run mecated standalone — the long-running daemon with interactive clients, durable sessions, and multi-replica support.
- Cloud-native k8s with mecak8s — Redis-backed, PVC-free Kubernetes deployment.
- Permissions & guardrails — the posture ladder, allow/ask/deny rules, and the headless ask reviewer.