Code Tasks

Queue coding-agent tasks, watch them move from queued through review, and run the code-worker sidecar that opens the pull requests.

Code Tasks is a queue for the AI coding agent. A task carries a plain-language description of a feature or bug fix; the code-worker sidecar is designed to pick it up, clone your repository, write the change with a coding agent, run the test suite, and open a pull request for you to review. The dashboard at Inbox -> Code Tasks shows every task and its current stage. This is an admin surface — only owners and admins can create or cancel tasks.

The backend queue and the dashboard work today, and the code-worker sidecar claims queued tasks and reports progress back. Because the worker runs untrusted, inbound-email-driven code, both bundled Compose stacks route it through a function-allowlist proxy (convex-fn-proxy) that holds the deployment admin key and forwards only the thirteen Convex functions the worker legitimately calls — so the worker itself holds no admin key. Set CODE_WORKER_PROXY_TOKEN in .env and the worker starts as soon as you enable the profile (see Setup).

What code tasks are

The feature has two halves: the queue that lives in the Owlat backend (the codeWorkTasks table), and the code-worker, a separate Docker container that does the actual coding work. The dashboard only reads and cancels tasks; the heavy lifting happens out-of-process in the sidecar.

Code Tasks is gated behind the Extract code tasks from inbox feature flag (inbox.codeTasks), which is off by default. Because the flag requires both inbox and ai.agent, enabling it also turns those on, and turning ai.agent back off cascades Code Tasks off again. When the flag is on, a Code Tasks entry appears in the Inbox section of the sidebar; when it's off, the sidebar entry disappears and the page itself is gated by the inbox.codeTasks feature requirement. See Feature flags for how to toggle it.

Automatic creation from inbound mail

When inbox.codeTasks is enabled, inbound messages classified as a feature request are automatically turned into queued code tasks via createFromInbound (idempotent per inbound message, so the same message never files two tasks). Manual creation via the codeWorkTasks.create mutation still exists in addition. See Current limitations.

Inbound tasks require a DMARC-aligned sender

An inbound message only spawns a code task when it clears three gates, all fail-closed: the message must carry a DMARC-aligned pass (computed by the MTA over the raw bytes at ingest), the sender must be a provisioned org member, and the request body must pass a content-safety check that rejects destructive, secret-exfiltrating or backdoor instructions. The DMARC gate is the primary anti-spoofing control — the member allowlist keys on the forgeable From header, so without an aligned DMARC pass a spoofed member address cannot direct the autonomous coding agent. A message that fails any gate still processes as normal inbound mail; it just never reaches the agent.

Creating and cancelling a task

A task needs one thing: a description of what you want done. Write it the way you'd write a ticket — "Add a CSV export button to the contacts table", "Fix the off-by-one in the digest date range". The clearer the description, the better the coding agent's output, because that text is passed verbatim to the agent and becomes the pull-request summary.

Creating a task requires the organization:manage permission (owners and admins). The new task starts in the queued stage and waits for the code-worker to pick it up.

Creating a task in the dashboard

The Code Tasks dashboard has a New code task button — in the header and in the empty state — that opens a description modal and files the task via the codeWorkTasks.create mutation (the same mutation you can call from the Convex dashboard, a script, or your own tooling). The description needs at least 10 characters. The lifecycle below applies regardless of how the task was filed.

To cancel, open the Code Tasks dashboard and use the Cancel button on a task card. The dashboard's Cancel button only appears on queued or running tasks, but the underlying codeWorkTasks.cancel mutation rejects only merged tasks — testing and review tasks are still cancellable via the backend. Cancelling marks the task failed with the message "Cancelled by user" — there is no separate "cancelled" stage.

Task lifecycle and the PR/test/cost surface

A task moves through a fixed set of stages. The code-worker advances it as it works and the dashboard reflects each transition in real time.

StageBadgeWhat it means
queuedQueuedFiled and waiting for the code-worker to claim it.
runningRunning (pulsing)The worker has claimed the task, set up a branch, and is running the coding agent.
testingTestingThe change is committed; the worker is running the test suite.
reviewReviewA pull request has been opened. The change is ready for a human to review and merge.
mergedMergedThe pull request was merged.
failedFailedThe task ran out of attempts, was cancelled, or produced no changes. The error is shown on the card.

A failed run does not end the task on the first stumble: while attempts remain it goes back to queued behind a growing delay and the worker picks it up again. See Automatic retries.

Each task card surfaces the work product as it becomes available:

FieldWhen it appears
BranchOnce the worker creates the feature branch (named code-worker/<task-id>).
Pull requestA clickable link to the PR, once the worker opens one.
Test resultsA tail of the test-runner output captured during the testing stage.
ErrorOn failed tasks only — the reason the task stopped.
CostThe LLM spend for the run, shown to four decimal places (e.g. $0.0123). The field exists in the schema and UI, but the code-worker does not currently report llmCost (it is never sent on completeWithPR/markFailed), so in practice Cost stays empty for worker-run tasks.
CreatedA relative timestamp ("just now", "12m ago", "3d ago").
Reaching the merged stage

The worker opens the pull request and moves the task to review. To advance it to merged automatically, configure a GitHub webhook pointed at POST /webhooks/github with the secret GITHUB_WEBHOOK_SECRET. When the PR is merged, GitHub notifies Owlat and the task moves to merged via markMergedByPrUrl. The merged stage is reachable in production — review is not terminal.

Setup: the code-worker service and its environment

The code-worker ships as a Docker sidecar. In the bundled Compose stack it runs under the inbox-codetasks profile, which the inbox.codeTasks feature flag turns on. It connects to your Owlat deployment over the Convex client, polls for the next queued task (every 10 seconds by default), and processes one task at a time.

The worker holds only a proxy token, not the admin key

Self-hosted Convex mints only a FULL deployment admin key — there is no per-function scoping — so the least-privilege boundary is built OUTSIDE Convex, in the convex-fn-proxy sidecar. It holds the real CONVEX_ADMIN_KEY and forwards ONLY the thirteen functions the worker drives the task with (codeWorkTasks: getNextQueued, claim, updateBranch, markTesting, completeWithPR, markFailed, reclaimStale, and the plugins/workerTasks: getNextQueued, claim, heartbeat, complete, fail, reclaimStale); any other function path is rejected 403. The worker authenticates to the proxy with CODE_WORKER_PROXY_TOKEN (passed through as its CODE_WORKER_CONVEX_KEY), which the proxy constant-time-compares, strips, and replaces with the admin key. The setup CLI still mints the admin key on first boot; you additionally set CODE_WORKER_PROXY_TOKEN (openssl rand -base64 32). If the worker exits at startup with CODE_WORKER_CONVEX_KEY or CONVEX_ADMIN_KEY environment variable is required, the proxy token is missing from .env.

Defence in depth around the sidecar

Two further controls ship with the code-worker profile. An allowlisted egress proxy (code-worker-egress, tinyproxy) is the only container that bridges the worker's isolated network to the internet: the code-worker Docker network is declared internal: true, so the worker has no route out of its own and cannot bypass the allowlist by ignoring HTTP_PROXY — its only path off-network is this proxy, which permits only the git remote and the LLM endpoint and denies all other outbound (infra/code-worker/tinyproxy.conf + tinyproxy.filter — add your LLM host to the filter). The worker's HTTP_PROXY/HTTPS_PROXY point at it, and NODE_USE_ENV_PROXY=1 makes the orchestrator's own fetch (GitHub API) honour those vars — node's built-in fetch ignores them otherwise. Enable Docker userns-remap on the host (infra/templates/docker-daemon.userns.json) so the container's confined-root maps to an unprivileged host uid. Neither weakens the existing uid-sandbox / capability-drop / read-only-rootfs isolation. A truly per-function Convex key remains a deferred upstream capability; the proxy is what closes the gap today. To revert to a direct, non-proxied deployment, point CONVEX_URL back at http://convex:3210 and pass CONVEX_ADMIN_KEY to the worker — convexClient.ts keeps that fallback.

For each claimed task the worker:

Claim the task

It atomically claims the oldest queued task, moving it to running so no other poll picks it up.

Set up a workspace and branch

It clones your repository (shallow, on the base branch) into a per-task workspace and checks out a new code-worker/<task-id> branch.

Run the coding agent

It runs the coding agent (OpenCode by default) with your task description as the prompt, with a 10-minute timeout. If the agent fails or produces no file changes, the run is reported as a failure — which requeues the task for another attempt, or fails it for good once the attempts are spent.

Commit and test

It commits the changes, moves the task to testing, and runs the test suite (vitest) with a 5-minute timeout, capturing the output tail.

Push and open a PR

It pushes the branch and — when GitHub credentials are configured — opens a pull request whose body includes your description and the test summary. The task moves to review with the PR URL, test results, and cost attached.

The container reads its configuration from environment variables:

VariablePurpose
CONVEX_URLThe endpoint the worker polls and reports to. In the shipped stack this is the convex-fn-proxy (http://convex-fn-proxy:3220), not Convex directly. Required.
CODE_WORKER_PROXY_TOKENShared secret the worker presents to convex-fn-proxy (and the proxy validates). Compose passes it through as CODE_WORKER_CONVEX_KEY. Generate with openssl rand -base64 32.
CODE_WORKER_CONVEX_KEYThe Convex key the worker authenticates with, preferred over CONVEX_ADMIN_KEY when set. In the proxied stack this is the proxy token; leave it unset only for a direct deployment that falls back to the admin key.
CONVEX_ADMIN_KEYHeld by convex-fn-proxy (injected on forwarded requests). The worker's fallback credential in a direct, non-proxied deployment. The worker throws at startup only if neither key is present.
HTTP_PROXY / HTTPS_PROXYPoint at code-worker-egress so all worker egress is filtered through the allowlisted forward-proxy. Set by Compose.
GIT_REPO_URLThe clone URL of the repository the agent works in.
GIT_BASE_BRANCHBranch to clone and target the PR against (defaults to main).
GITHUB_TOKENGitHub token used to open the pull request.
GITHUB_OWNER / GITHUB_REPOThe repo the PR is opened against. If both are unset, the worker pushes the branch but skips PR creation.
LLM_BASE_URL / LLM_API_KEYLLM configuration for the coding agent. These are the only two LLM vars the worker child actually reads.
LLM_PROVIDER / LLM_MODELPassed through Compose env for convenience, but not read by the worker itself.
OPENCODE_BINPath to the OpenCode binary (defaults to opencode).
POLL_INTERVAL_MSHow often to poll for queued tasks (defaults to 10000).
WORKSPACE_ROOTWhere per-task clones live inside the container (defaults to /workspace).
The worker has write access to your repo

The code-worker clones, commits, pushes, and opens pull requests with the credentials you give it, and runs an autonomous coding agent against your codebase. Scope GITHUB_TOKEN to a single repository, point GIT_BASE_BRANCH at a branch you protect with required reviews, and treat every generated pull request as untrusted until you've read it.

For where these variables live in the deployment and how to enable the inbox-codetasks profile, see Self-Hosting Configuration and Feature flags.

Automatic retries

Most ways a code task falls over are transient — the LLM endpoint hiccups, the clone times out, the container is restarted mid-run — so a failed run is retried instead of ending the task.

Every claim counts as an attempt. A task gets 3 attempts in total; when a run fails and attempts remain, the backend puts the task back in queued and stamps it with the earliest time it may be claimed again. The delay grows with each failure — 1 minute after the first, 5 minutes after the second — so a hard-down LLM endpoint isn't hammered by a task that cannot possibly succeed yet. The worker only sees a task once that window has elapsed, and it processes any other ready task in the meantime. When the last attempt fails, the task becomes failed for good with the final error on the card.

Two rules bound this:

  • Cancelling always wins. A task you cancel is terminal immediately. If the worker was mid-run, the failure it reports afterwards can't put the task back in the queue.
  • A crashed worker doesn't strand a task. On startup the worker reclaims every task left in running or testing by its predecessor; each one is requeued behind the same backoff (or failed, if its attempts are spent) rather than sitting in-flight forever.

The retry ceiling and the delays are backend-owned, so a stale worker image cannot widen them.

Current limitations

Code Tasks is an early feature. Keep these in mind:

  • Cost is not reported. The code-worker never sends llmCost, so the Cost field stays empty for worker-run tasks even though the schema and UI support it.
  • One task at a time, in order. The worker processes the oldest ready queued task per poll; there's no parallelism or prioritisation.
  • Retries are not configurable. The attempt ceiling and the backoff schedule are fixed in the backend; there is no per-task or per-instance setting, and no way to hand-retry a task that has spent its attempts other than filing a new one.
  • A retrying task looks queued. While a task waits out its backoff it shows the Queued badge; the dashboard doesn't yet surface the attempt count or when the next attempt is due.

For the broader picture of how Owlat's agent handles inbound conversations and drafts, see AI Agent & Autonomy and the Team Inbox.