drydock

Submitting tasks

A task is one unit of work: a repo, an instruction, and an agent. drydock clones the repo into a throwaway VM, runs the agent, captures a git diff, and waits for your approval before anything reaches origin.

The basic flow

In one shell, fire the task. It blocks until the agent runs and you approve the diff (typically a few seconds to a few minutes, plus your review time):

drydock submit \
  --repo git@github.com:your-org/your-repo \
  --instruction "Add a one-line comment to README.md explaining the project."

A macOS notification fires when the diff is ready (opt out with DRYDOCK_NO_NOTIFY=1).

Before the first submit against an unfamiliar repo, drydock doctor --repo <path> preflights a local checkout with no API spend and no container boot: size vs the stage cap, toolchains vs what the image ships, registry hosts vs your egress allowlist, and diff_policy collisions (see Troubleshooting).

From a GitHub issue

A GitHub issue can be the task instruction directly — no copy-pasting:

# 1. Plan first: the agent produces an implementation plan, changes nothing.
drydock submit --issue https://github.com/o/r/issues/42 --plan
#    (sugar: drydock plan https://github.com/o/r/issues/42)

# 2. Review the plan.
drydock inspect <id>            # renders the plan inline
less ~/.drydock/audit/<id>.plan.md

# 3. Happy with the plan? Run the same issue for real.
drydock submit --issue https://github.com/o/r/issues/42

--issue fetches the issue host-side via your authenticated gh (with a curated environment, not a full copy of yours) and derives --repo from the URL when you omit it. Your GitHub credentials never enter the sandbox — only the issue text does, folded into the prompt with the title, labels, and a size-capped body.

Treat that text as untrusted input: anyone who can write an issue on the repo is now writing instructions to your agent, exactly like a hostile AGENTS.md already could (see the threat model). The human gates are the boundary — the plan you review, and the diff gate every implementing run still passes through.

A --plan run is scope-gated in the broker, not just prompted: it terminates with outcome planned before the verify/push logic, so nothing is ever pushed from a plan run regardless of what the agent did in its VM. Any stray diff is still captured beside the audit log for your review, and drydock retry <id> of a plan run re-plans (it never silently escalates to an implementing run).

Queue (durable, unattended)

drydock submit blocks your shell until the task finishes (and until you approve the diff). drydock queue add takes the same flags--issue, --plan, --instruction-file, --egress-extra, all of it — but returns the moment brokerd has durably persisted the task:

drydock queue add --repo git@github.com:o/r --instruction "fix the flaky test"
drydock queue add --issue https://github.com/o/r/issues/42 --auto-approve

drydock queue list           # ID / STATE / AGE / ATTEMPTS / CI / RETRY / REPO / REASON
drydock queue cancel <id>    # dequeue a waiting item, kill it if running, or
                             # cancel the CI watch if it is in awaiting_ci

Each item moves through a broker-enforced state machine:

queued → preparing → running → verifying → awaiting_review → completed
   │                                                │      ↘ dead_letter
   ↓                                                ↓
dead_letter                                   awaiting_ci → completed
(spend-capped                                             ↘ ci_failed
 CI retry only)                                           ↘ dead_letter
        (cancelled is reachable from every non-terminal state)

The queued → dead_letter edge has exactly one writer: the dispatcher dropping a broker-initiated CI retry whose vendor spend cap exhausted before it could dispatch. A human-submitted item never takes it — it parks instead. See the spend-ceiling bullet below.

Watching the PR's CI (opt-in, off by default)

With ci.watch enabled, a queued task does not finish at the push. Once the branch is pushed and the PR is open, the item moves to awaiting_ci and brokerd observes that PR's checks host-side, on a timer, until they conclude.

The watch holds no concurrency slot and keeps no stage — it is a separate bounded poll keyed off a durable marker in the audit dir, so other tasks keep dispatching normally and a restart resumes the watch with its original deadline (a crash loop cannot extend the window).

drydock queue list gains a CI column showing the last observed conclusion and the PR number:

ID                                STATE              AGE  ATTEMPTS  CI                RETRY                             REPO                                      REASON
0123456789abcdef0123456789abcdef  awaiting_ci        12m         1  pending #431      -                                 o/r                                       -
89ab456789abcdef0123456789abcdef  ci_failed          41m         1  failed #430       -                                 o/r                                       -
cdef456789abcdef0123456789abcdef  completed           2h         1  passed #429       -                                 o/r                                       -

(The RETRY column is - unless the bounded retry is on; it is described below.)

The terminals it can reach, and exactly what each one claims:

observation terminal means
every check passed completed observed success
the PR has no checks configured completed observed: there is no CI gate to wait for
at least one check reached a terminal non-success (failed, errored, timed out, or was cancelled) ci_failed observed failure
the watch window expired dead_letter no conclusion was observed
repeated read failures, or an unwatchable PR dead_letter no conclusion was observed

The asymmetry is deliberate and is the whole point of the feature: ci_failed means the broker watched a check fail, and nothing else ever produces it. A watch that ends without an answer — a timeout, a run of API errors, a marker lost to a restart, or the watch being switched off while an item was parked — lands on dead_letter with the reason in last_error. It never lands on completed, because absence of evidence is not success. If you want to know whether CI passed, completed plus ci_state: passed is the only pair that says so.

Two behaviours follow from that same rule and are worth knowing before you turn the watch on:

Only the check conclusions are read. No CI log text is fetched, parsed, stored, or displayed anywhere on this path, and no log content can influence any decision. What you see in the CI column is broker-authored vocabulary and a PR number.

The observation is also appended to the task's audit trail as a {"type":"ci_observation","src":"broker",…} record. It is deliberately not written as a result row: the task's own terminal row still says the task pushed cleanly (it did), so a CI failure never relabels a successful push in drydock tasks, drydock stats, or the web UI, and never disturbs the spend accounting those readers derive from that row.

The direct consequence, worth stating plainly: the queue is the CI surface. drydock queue list, GET /queue, and the raw ci_observation record are where a CI verdict appears. drydock tasks, drydock stats, and the web UI classify a task from that last result row, so a pushed task keeps reading ok in all three regardless of what its PR's CI did. That is the intended reading — the task did what it was asked to; its branch's CI is a separate fact — but if you want the CI answer, look at the queue.

Cancelling a watch. drydock queue cancel <id> works on an awaiting_ci item: it cancels the watch (no further API call is spent on that PR) and moves the item to cancelled. There is no other way out short of the deadline, which is why it is wired: cancelled really is reachable from every non-terminal state.

With ci.watch off — the default — none of this happens: a pushed queue item completes the moment it pushes, exactly as it always has, and no marker is written and no API call is made on a timer.

Turning it on (and back off), the poll/deadline knobs, and the fact that it puts your host gh credential on a timer are covered in Configuration § host-side CI observation.

Retrying an observed CI failure (opt-in, off by default)

The watch above only observes. ci.max_attempts is the second half: it lets an observed CI failure enqueue a bounded number of fresh retry tasks. It is off by default (0), and it does nothing at all unless ci.watch is also true — the decision is only ever reached from a terminal CI observation, and those exist only when the watch is on.

approval_timeout: 2h   # REQUIRED (non-zero) once max_attempts > 0
ci:
  watch:        true
  max_attempts: 2      # at most two retries per chain; 0 = off, ceiling 10

max_attempts > 0 requires a non-zero approval_timeout, and brokerd refuses the pair at load. A retry child is an unattended task that re-poses the human diff gate, and it holds one of your max_concurrent_tasks slots for its whole life — gate included. With approval_timeout: 0 ("wait forever") two PRs that fail CI at 03:00 fill the default cap of two slots with children parked at gates nobody is at, and every task you submit afterwards sits queued indefinitely with nothing warning you why. Restarting brokerd does clear the held slots — the semaphore is in-process, and the boot resume of a parked gate does not take one — but "restart the daemon" is not a plan for an unattended overnight queue, and the daemon is meant to run for weeks. Pick a timeout you would actually be happy auto-denying at.

The whole flow, end to end. You submit a task. It runs, you approve its diff, it pushes agent/<id> and opens PR #1. The item parks in awaiting_ci and the broker polls that PR's check conclusions. If it observes a failure, the item lands ci_failed — and then, if max_attempts allows, the broker enqueues a new task carrying the failure forward:

task A  →  PR #1  →  CI failed  →  ci_failed        (retry_task_id → B)
                                        ↓
task B  →  PR #2  →  CI failed  →  ci_failed        (retry_of ← A, attempt 1)
                                        ↓
task C  →  PR #3  →  CI passed  →  completed        (retry_of ← B, attempt 2)

A retry is a new task, never a re-run. New id, new queue item, new credential lease, new VM, new branch, and its own pull request. The parent is already terminal and stays that way. The practical cost of that, stated plainly: each attempt opens its own PR, and closing the superseded one is your job. drydock does not close it — an automatic close is a write against your remote that nobody approved, and the superseded PR is often the clearest record of what went wrong.

Every attempt re-clones the default branch, so every diff is cumulative. The retry does not branch off the previous attempt's work. It starts from a clean clone of the repository's default HEAD and receives the prior attempt's diff and the failed check names as capped, sanitized, fenced text in its instruction. That means each attempt's approval gate shows you the whole change against the default branch, not an increment — and it is a safety property, not a convenience:

Re-cloning costs the agent redoing the work. It buys a gate that is honest on every attempt.

Only an observed failure retries. ci_failed is the sole trigger. A watch that timed out, gave up after repeated read errors, or lost its marker to a restart lands dead_letter and retries nothing — "we could not tell" is not evidence that a build is broken, and a retry on it would spend a fresh budget to learn nothing, on a timer, unattended. "This PR has no checks configured" and a pass both land completed and likewise retry nothing.

A retry always re-poses the human diff gate. auto_approve is force-cleared on every child, even when the parent had it set, so a retry can never inherit gate-bypass from its parent. The child is otherwise an ordinary queued task: its own slot, its own lease, its own verification, its own approval. sensitive is preserved and never downgraded. A plan-only parent is never retried (a plan run never pushes, so it can never have had CI), and a synchronous drydock submit task is never retried either — it has no durable queue record to carry a bound.

What it costs, plainly. Each attempt mints a fresh full task_budget_usd — it deliberately does not share the parent's budget, since a shared budget would 402 mid-run against money the parent already spent. So the worst case for one failing task is:

max_attempts × task_budget_usd     on top of the parent's own task_budget_usd

With task_budget_usd: 2.00 and max_attempts: 3, one task that keeps failing CI can cost $8.00, not $2.00. That is why the default is 0 and the configuration ceiling is 10. If aggregate_budget_usd is set and its window is exhausted, a retry is refused outright, not parked: an operator's own queued task waits for the window to slide because a human is waiting for it, but a broker-initiated retry nobody asked for must not sit queued for hours and then dispatch against a base that has moved on. That holds at both ends — the decision declines to enqueue, and a child whose vendor cap exhausts in the gap before dispatch is dropped to dead_letter by the dispatcher rather than parked. Either way the reason is recorded: in the audit's retry_detail for a refused decision, and in the item's last_error for a dropped child — which drydock queue list shows in its REASON column and GET /queue returns as last_error. (A dropped child never runs, so it writes no audit terminal row of its own and appears nowhere in drydock tasks; the queue row is its only record.)

Without aggregate_budget_usd, both of those refusals are a no-op. They test the same aggregate cap, which is unset by default and is api_key-mode-only by design, so it is absent entirely in subscription mode. In that configuration the only bound on retry spend is the per-chain product above — per failing task, with nothing capping how many chains run at once. brokerd prints a warning at boot when ci.max_attempts > 0 with no aggregate cap. It does not refuse the pair, because that would make the feature unusable in subscription mode; if you are arming retry unattended and can use an aggregate cap, set one.

A retry that cannot say what failed is refused. The check rollup is persisted on the <id>.ci.json marker alongside the terminal state, so a conclusion replayed after a crash — or after a queue write that failed on a full disk — still carries the evidence it was derived from. An observation that carries none is declined rather than turned into a fresh full-budget task that would just re-run blind.

Following a chain. drydock queue list's RETRY column shows each item's depth and its links:

ID                                STATE              AGE  ATTEMPTS  CI                RETRY                             REPO                                      REASON
1111111111111111111111111111aaaa  ci_failed           2h         1  failed #431       #0 ->222222222222…                o/r                                       -
2222222222222222222222222222bbbb  ci_failed           1h         1  failed #432       #1 <-111111111111… ->333333333333…  o/r                                     -
3333333333333333333333333333cccc  completed          20m         1  passed #433       #2 <-222222222222…                o/r                                       -

#N is the attempt depth (0 for the task you submitted). <- points at the parent this item retries; -> points at the retry this item's own failure enqueued. GET /queue carries the same three fields unabbreviated as attempt, retry_of, and retry_task_id, and the audit's ci_observation record carries them plus a retry_detail string. retry_detail is the broker's reason for the ending it chose, and it is written only once the decision was actually reached. It is empty whenever the decision was never reached at all — the retry is off, the observation was not a failure, the parent's terminal transition did not land, the task was a synchronous drydock submit with no durable queue item (so every non-queued task), a child was already recorded, or brokerd was shutting down. It names the reason when the decision was reached and refused: the bound, the spend cap, a refused build, or an enqueue that failed. On success it reads enqueued retry attempt N of M.

The bound is on disk. The attempt counter lives in the persisted queue record and its CI marker, never in memory, and the retry decision runs only after the parent's terminal reached disk. A brokerd killed mid-decision can end a chain one attempt short; it can never restart one, mint two children for one parent, or extend a chain past max_attempts. Lowering max_attempts (or setting it to 0) takes effect at the next decision and stops the chain there; it does not cancel a retry that was already enqueued. Raising it lets an in-flight chain continue up to the new, higher bound.

Untrusted text, and what drydock claims about it. The retry instruction carries two attacker-influenceable inputs: the failed check names (a repository's own workflow file chooses them) and the prior attempt's diff (agent-written). Both are control-character sanitized, byte-capped, and fenced under delimiters whose tokens are derived from all the non-broker-authored bytes in the instruction and proven to occur in none of them, so neither input can forge a convincing end line for its own section or for the other one.

Exactly one attempt's worth of each, at every depth. A retry's instruction is your original task plus one CI-evidence section plus one prior-diff section — never the parent's instruction plus another pair. The original is carried forward on its own broker-owned field, so attempt 10's prompt is the same size as attempt 1's and carries only the most recent attempt's diff. That is what makes the ceiling of 10 actually reachable (an accumulating instruction hits the 64 KiB task-body cap around attempt 3) and what keeps the fence's claim — "the genuine delimiters carry these tokens and no others" — true past the first hop.

What that is not is a filter: the honest claim is that this text decides nothing — the retry, the bound, the gate, the repository, and every other control decision derive from broker-observed check conclusions alone — and that the human diff gate is the boundary, exactly as it is for issue-sourced instructions. No CI log text is fetched at any point. See N2 in the threat model.

To turn it off: set ci.max_attempts: 0 (the default) and restart brokerd, or turn the whole watch off with ci.watch: false. Check DRYDOCK_CI_MAX_ATTEMPTS is not exported in the daemon's environment — env wins over the file — and confirm with drydock policy explain.

A queued task's terminal metrics row records the wait as stage_ms.queued (enqueue → dispatch); drydock stats aggregates it as the queue-wait p50/p95 line. Synchronous tasks omit the field entirely.

Push-credential preflight

Before the sandbox boots, drydock runs git push --dry-run against your repo with the exact branch it would later push (agent/<id>). A dry-run push authenticates and computes ref updates without sending objects or moving refs: one remote round trip, before the sandbox boots. A missing local credential (no helper, no key, no agent) fails even faster, in milliseconds, without touching the network at all. Either way, if it fails, the task ends immediately with a classified reason and nothing spent: no VM, no agent run.

The consequence is plain: a task against a repo you cannot push to fails at submit, not after a long agent run. There is no opt-out.

git never prompts during this check. HTTPS needs a credential helper (gh auth setup-git); SSH runs in BatchMode unless you've set GIT_SSH_COMMAND yourself.

The approval gate

Nothing reaches your real repo until you say so. Review and act on a pending task from any shell:

drydock pending            # tasks awaiting you (egress + diff gates both shown)
drydock review <id>        # diff in $PAGER, then prompt y/N (the one-shot path)
# … or step by step:
less ~/.drydock/audit/<id>.diff
drydock approve <id>       # … or: drydock deny <id>

drydock review is the fast path; approve / deny are the explicit controls. A denied task keeps its diff in the audit dir but never pushes.

Diff policy: caps and second-look acknowledgments

The optional diff_policy block in config.yaml (see Configuration) shapes what reaches this gate:

If the branch pushes but the PR can't be opened (e.g. gh isn't authenticated), drydock reports it as pushed with a hint to open the PR manually; it never loses your work to a failed PR step.

Execution profiles (setup, per repo)

Some repos need preparation before an agent can work: dependencies installed, code generated. An execution profile in ~/.drydock/config.yaml (see Configuration) gives a repo setup commands plus readiness commands that gate the run:

profiles:
  repos:
    "github.com/you/yourrepo":
      setup:
        - ["npm", "ci"]
      readiness:
        - ["node", "--version"]
      timeout: 10m      # per command; 0 = the default (10m)

The commands run in the sandbox against the task's live work tree, before the agent starts — what setup installs is exactly what the agent sees. They live in host config only, so the sandboxed agent can never edit its own setup phase. Setup VMs get egress through the squid proxy (registries on your allowlist are reachable) but no model gateway and no credentials. Repos that opt in with cache: true (see Configuration) reuse dependency downloads across tasks: setup VMs populate a host-side, content-addressed cache mounted at /deps — read-write in setup, strictly read-only in the agent VM, keyed per repo + lockfile (no cross-repo sharing, and no cache at all for a repo without a lockfile). Without the opt-in the per-task workspace is wiped at cleanup and setup runs from scratch on every task. Each command is a self-contained argv in its own VM run — shell state (cd, exports, virtualenv activation) does not carry between commands.

Setup is always enforced; there is no advisory mode. Verdicts are the exit codes the broker observes (nothing a command prints can flip a status); commands run in order and stop at the first non-pass (later ones record skipped). Any failure, timeout, or infrastructure error fails the task closed before any API spend: the terminal outcome is setup_failed, the agent VM never boots, and no credential is ever injected into any VM — a broken workspace costs you $0 of model budget.

While it runs, the task shows a setting_up stage — between preparing and running — in the submit stream, drydock status, and the web UI. The evidence lands in the trust brief (drydock inspect <id>): overall status, the setup VMs' egress posture, the dependency-cache line when the repo opts in (hit/miss/disabled: no lockfile plus the cache entry's key prefix — all broker-observed), per-command exit codes and durations. The commands' combined output is kept (display-only, size-capped, never parsed) at ~/.drydock/audit/<id>.setup.log.

Verification (optional, per repo)

drydock can run your own checks — build, tests, lint — against the agent's work before the diff reaches the approval gate. Configure them per repository in ~/.drydock/config.yaml; keys must be the canonical host/owner/repo form (a non-canonical key is a config error, not a silent never-match):

verify:
  repos:
    "github.com/you/yourrepo":
      commands:
        - ["go", "build", "./..."]
        - ["go", "test", "./..."]
      timeout: 10m      # per command; 0 = the default (10m)
      required: false   # true = anything but "passed" blocks the push

Each command gets exactly this, no more:

Verdicts are the process exit codes the broker observes from each VM — nothing a command prints can change a status. Commands run in order and stop at the first non-pass (later ones record skipped). The overall status is passed only when every command passed; a timeout or infrastructure error makes it inconclusive, which is never treated as a pass.

While it runs, the task shows a verifying stage — between running and awaiting_approval — in the submit stream, drydock status, and the web UI. Review the evidence with drydock inspect <id>: overall status, the VM's capability posture, per-command exit codes and durations, and the verified tree hash. The commands' combined output is kept (display-only, size-capped, never parsed) at ~/.drydock/audit/<id>.verify.log.

Push outcomes

After you approve a diff, drydock pushes the single commit to a new remote branch (agent/<id>) and optionally opens a PR. A single-ref git push is atomic: the remote branch either receives the whole commit or is left unchanged. push_failed therefore guarantees nothing landed on the remote for that task, and the captured diff is always preserved in the audit .diff file for every outcome that produced a diff — verify_failed included; only tasks that never captured one (no_diff, or a failure before diff capture) have no .diff.

drydock classifies push failures and recovers from the recoverable ones:

When every recovery path is exhausted, drydock reports outcome=push_failed with the classified reason (transient, auth, protected, non_fast_forward, or unknown). The drydock tasks row shows push failed for that task.

push_failed is retry-safe: drydock retry <id> re-runs the task under a new id (a new agent/<newid> branch), so a retry never collides with the failed attempt.

Not every task reaches a push attempt. A diff denied at the approval gate reports outcome=denied and never pushes; a task killed mid-run reports outcome=cancelled; a run that produces no diff reports outcome=no_diff and still displays as ok (a clean no-op isn't a failure); a task whose execution profile does not pass reports outcome=setup_failed before the agent ever runs (no diff exists — nothing ran to produce one); a task whose required verification does not pass reports outcome=verify_failed and never reaches the approval gate; a diff that violates diff_policy (caps or blocked_paths) reports outcome=policy_blocked — shown as policy blocked — and likewise never reaches the gate. drydock tasks, drydock stats, and the web UI all show these outcomes distinctly from pushed and push_failed.

Operator surface

drydock status             # brokerd up?, breakdown (setting up · running · verifying · egress · diff · pushing)
drydock inspect <id>       # trust brief: broker-observed evidence incl. setup + verification
drydock tasks              # recent runs: id, age, duration, cost, outcome
drydock logs <id> [-f]     # stream-json audit (use -f to follow)
drydock stats [--since 30d] [--by agent|vendor|repo|day|week] [--json]
                           # aggregate run metrics across tasks
drydock kill <id>          # cancel the in-flight task (VM down + gate unblocked)
drydock queue add|list|cancel
                           # durable unattended queue (see Queue above)
drydock doctor [--repo <path>]
                           # smoke-test the sandbox setup, or preflight a local
                           # repo with --repo (no API spend, no container)
drydock redteam            # run live containment attacks on your own sandbox (no API spend)

Prefer a browser? drydock ui puts the board, the diff/approve gate, and history in a local web app; see Web UI.

Run metrics: drydock stats

drydock stats [--since 30d] [--by agent|vendor|repo|day|week] [--json]

drydock stats aggregates outcome rates, duration and gate-wait percentiles, spend, and egress-widen frequency straight from the audit dir (no brokerd needed). Tasks from before this feature shipped still count toward outcomes, durations, and cost, but report their gate-wait and request-count fields as absent rather than zero.

The audit result row alone cannot tell a diff denied at the approval gate apart from a normal success, a mid-run kill apart from a plain agent error, or a fail-closed diff-capture failure (an oversized or unreadable staged diff) apart from a normal success: the terminal metrics row's outcome field carries the distinction (denied, cancelled, alongside pushed, push_failed, error, and no_diff), and drydock tasks, drydock stats, and the web UI all read it. no_diff still displays as ok (unchanged operator muscle memory: a clean no-op run isn't a failure). Audit files from before the outcome field shipped fall back to the result row alone: denied and fail-closed diff-capture failures read as ok, cancelled reads as error, same as today.

Audit stream format

Each task's audit file (~/.drydock/audit/<id>.jsonl) is newline-delimited JSON: one event per line, in the order they happened. Separately, the live drydock submit --json / POST /tasks NDJSON stream now stamps every event with a ts field (RFC 3339); that timestamp is on the submit stream only, not persisted into the audit file. The audit file ends with a broker-authored row, {"type":"metrics","src":"broker"}, holding stage durations (stage_ms.queued, .preparing, .setup, .running, .verifying.queued/.setup/.verifying omitted when the task never ran those stages, so a synchronous submit's row has no queued at all — and .pushing; the stages partition the task's wall-clock, so preparing ends where setup begins), egress/approval gate waits, the admitted request count, spend, the terminal outcome (pushed, denied, cancelled, push_failed, setup_failed, verify_failed, policy_blocked, error, or no_diff), and the egress-widen outcome; if brokerd ever appends more than one (e.g. after a resumed task), the last such row wins, so readers should take the last metrics line, not the first.

Variations

# Pick the agent for this task: claude (default) | codex | gemini | opencode
drydock submit --repo … --instruction "…" --agent codex
# opencode runs any OpenAI-compatible model (see Bring your own model)
drydock submit --repo … --instruction "…" --agent opencode

# Long prompt from a file
drydock submit --repo … --instruction-file ./task.md

# Pipe from stdin
echo "Refactor the egress compiler" | drydock submit --repo … -

# Pick a specific model (overrides default_model in config)
drydock submit --repo … --instruction "…" --model claude-sonnet-4-6

# Skip the approval gate (trusted batch run; see the threat model first)
drydock submit --repo … --instruction "…" --auto-approve

# Open the resulting PR/MR as a draft
drydock submit --repo … --instruction "…" --draft

# Request additional egress (host:port[,port], repeatable; human-gated)
drydock submit --repo … --instruction "…" \
  --egress-extra internal.example.com:443

# Suppress progress; print only the final outcome line (useful in scripts)
drydock submit --repo … --instruction "…" --quiet

# Mark the task sensitive in the audit trail
drydock submit --repo … --instruction "…" --sensitive

# Stream raw NDJSON events (one JSON object per line)
drydock submit --repo … --instruction "…" --json | jq -c 'select(.event=="result")'

See Egress & widening for --egress-extra.

Platform selection (PR/MR)

--repo must be a git URL (https://, git@, or ssh://); local paths are rejected. The PR/MR adapter is chosen by --platform:

Self-hosted GitLab and Gitea need an explicit --platform.

HTTP API

If you'd rather hit the broker directly:

SOCK=$TMPDIR/drydock-$(id -u)/drydock.sock
curl --unix-socket "$SOCK" http://_/tasks \
  -H 'content-type: application/json' \
  -d '{ "repo_ref": "git@github.com:o/r", "instruction": "..." }'