Course · OpenCode · Sharing & headless
The server, CI, and GitHub
A one-shot opencode run per task, with the output landing on your screen, is the right shape for a script you babysit. It’s the wrong shape for the thing feedmill - the course’s illustrative Go feed aggregator - actually needs from here on: an OpenCode review on every pull request, and an agent that can pick up an issue and open a PR without you in the loop at all. For that, OpenCode stops being a command you type and becomes something other systems run and talk to: a CI job, a server, a GitHub workflow.
That reframe is already baked into the tool. The full-screen TUI (terminal user interface) you get when you type opencode isn’t OpenCode - it’s one client of an OpenCode server. Drop the TUI and you can keep the server.
The server is the product; the TUI is just a client
Section titled “The server is the product; the TUI is just a client”OpenCode is a client/server program. When you launch the TUI it quietly starts a server, picks a port, and connects to it - the interface is a view onto a backend that does the real work. You can start that backend on its own with no interface attached (an illustrative startup):
$ opencode serve --port 4096opencode server listening on http://127.0.0.1:4096That’s a headless HTTP server exposing OpenCode’s full API - sessions, models, tools, the loop, all of it - over the network, and it publishes its own OpenAPI 3.1 spec at /doc, so anything that can speak HTTP can drive it, and anything that speaks OpenAPI can generate a client for the whole surface instead of reverse-engineering routes. The defaults are local and deliberate: serve binds to port 4096 on hostname 127.0.0.1 and stays there until you change it (server documentation, as of October 2026). The TUI is now just the most convenient of many possible clients, not a requirement.
The moment you bind it anywhere reachable, lock it down; who can reach the server is a control decision. OpenCode reads OPENCODE_SERVER_PASSWORD and turns on HTTP basic auth when it’s set; the username defaults to opencode (override with OPENCODE_SERVER_USERNAME). The same basic-auth pair guards the opencode web client too, not just serve:
$ OPENCODE_SERVER_PASSWORD=$(openssl rand -hex 16) \ opencode serve --hostname 0.0.0.0 --port 4096The discipline is the same whenever feedmill’s work leaves your machine: decide on purpose what’s allowed to reach it. A server with no password on a routable host is an open agent anyone can run commands through.
Attach a client when you need to look inside
Section titled “Attach a client when you need to look inside”A headless server is great until something goes wrong inside it and you want eyes on the running session rather than a log scrape. You don’t restart anything - you attach a client to the server that’s already up (an illustrative session):
$ opencode attach http://localhost:4096That gives you the familiar TUI, but pointed at the existing backend instead of spinning up its own. The session the server is running is right there in front of you. The same --attach flag works on run when you’d rather drive the live server with a one-shot prompt:
$ opencode run --attach http://localhost:4096 \ "summarize what the last review session flagged"If the server is password-protected, both attach and run take the credentials inline with --username/-u and --password/-p - or, if you skip the flags, they fall back to OPENCODE_SERVER_USERNAME (default opencode) and OPENCODE_SERVER_PASSWORD from the environment, the same pair serve reads (as of October 2026; CLI documentation):
$ opencode attach http://feedmill-ci:4096 -u opencode -p "$OPENCODE_SERVER_PASSWORD"So the server can run unattended on a host you own, such as a feedmill cron box or a self-hosted runner, and you can still walk up to it interactively the instant you need to. The every-PR review below does not use the server: it is a one-shot opencode run on a hosted runner that is gone when the job ends, so nothing is left to attach to. Server and client are decoupled on purpose.
Put it in a container for CI
Section titled “Put it in a container for CI”CI wants one thing from a tool: that it runs the same way every time, on a machine that didn’t exist a minute ago. A container gives you exactly that - a fixed OpenCode version, a clean filesystem, and no dependence on whatever happens to be installed on the runner. That’s the difference between “the review passed on my laptop” and “the review runs identically on every PR for the next year.” Treat any published OpenCode image and its tags as release-specific: verify the registry entry for the exact version you pin. If it is unavailable or you need tighter reproducibility, own a small Dockerfile with OpenCode plus feedmill’s toolchain. The published image is Alpine-based, has no Go toolchain or git, and already sets opencode as its entrypoint (Dockerfile at v1.18.32; the registry carries a 1.18.32 tag as of October 2026), so feedmill’s own file only adds Go and git on top of the tag you verified:
FROM golang:1.22-alpine AS goFROM ghcr.io/anomalyco/opencode:1.19.4RUN apk add --no-cache git && git config --global --add safe.directory /srcCOPY --from=go /usr/local/go /usr/local/goENV PATH="/usr/local/go/bin:${PATH}"WORKDIR /srcThe pattern is the same one-shot opencode run shape, moved inside an image: a container that has OpenCode and feedmill’s Go toolchain, your provider key passed in as a secret, and a single opencode run per container. OpenCode’s LSP diagnostics (off by default, enabled with "lsp": true in opencode.json, the project config file, as of October 2026; LSP documentation) reach the agent only through the output of the edit, write, and patch tools (edit.ts at v1.18.32), so they matter only for CI runs that edit code.
One disposal note if you run this shape on a machine you own - a cron box, a self-hosted runner - with the repo bind-mounted into the container (an illustrative run from the repo root):
$ docker build -t feedmill-opencode .$ docker run --rm -v "$PWD":/src -e ANTHROPIC_API_KEY feedmill-opencode \ run "run go test ./... and flag any feed-parsing regressions"--rm removes the container, never the bind-mounted checkout. Git is what makes a wrecked checkout disposable; the flag only cleans up the box around it.
The workflow below reaches the same fixed-version property on GitHub’s hosted runner without an image: it installs one pinned OpenCode release, set in OPENCODE_VERSION. To run the job inside your container instead, replace the install step with a job-level container: that names your pinned image. Here’s the full workflow, end to end:
name: OpenCode PR Review
on: pull_request: types: [opened, synchronize]
permissions: contents: read pull-requests: write
jobs: review: runs-on: ubuntu-latest env: OPENCODE_VERSION: "1.19.4" # pin the release you tested (latest v1 as of October 2026) steps: - uses: actions/checkout@v4 with: fetch-depth: 0
- uses: actions/setup-go@v5 with: go-version: "1.22"
- name: Vet and test run: | go vet ./... go test ./...
- name: Install OpenCode run: curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
- name: Run OpenCode review id: review env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASE_REF: ${{ github.base_ref }} OPENCODE_CONFIG_CONTENT: '{"permission":{"edit":"deny"}}' run: | opencode run \ --model "${{ vars.OPENCODE_MODEL }}" \ --format json \ "review the changes in git diff origin/$BASE_REF...HEAD. flag dedup or feed-parsing regressions. be terse." \ > review.jsonl
- name: Extract review text run: | jq -r 'select(.type == "text") | .part.text' review.jsonl > review.txt test -s review.txt || { echo "no review text in review.jsonl" >&2; exit 1; }
- name: Post review as PR comment uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('review.txt', 'utf8').trim(); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: `### OpenCode Review\n\n${body}`, });The same opencode run you would run from your terminal, now inside a runner with the Go toolchain and your provider key injected as a secret. go vet and go test run as their own steps before OpenCode is installed, so a failing test turns the job red by its exit code, not by the agent’s report, and no model call is spent on a PR that fails them. The prompt names the diff base, because the checkout is a merge commit and the agent can’t guess what “this diff” means; fetch-depth: 0 fetches the full history so that origin/$BASE_REF exists for that git diff. Each text event in the JSON stream carries the assistant’s text at .part.text (the event types are listed in Run OpenCode headless), and the extract step fails the job when it finds none, so an empty review never posts as a clean one. Nothing in this job asks for approval: the stock build agent (OpenCode’s default editing agent) runs bash and the edit tools under the default allow, and in opencode run a request that would ask is rejected rather than left waiting (run.ts at v1.18.32; the permissions documentation documents --auto as the override, as of October 2026). The word “review” in the prompt does not stop edits under that allow, so the OPENCODE_CONFIG_CONTENT line enforces it: that inline config loads after the repo’s opencode.json (config documentation, as of October 2026) and denies the edit, write, and patch tools, unless an agent.build permission in the repo’s config allows them again. Bash stays allow, so a shell command can still change the checkout, but the token can’t push and the runner is discarded when the job ends. Two limits come with the pull_request trigger. On a pull request from a fork, GitHub passes no secrets and a read-only GITHUB_TOKEN, so the review step has no provider key and fails: a fork PR shows a failed check and gets no review comment, after the vet and test steps have run (the comment step could not write there anyway) (GitHub Actions events documentation, as of September 2026). On a same-repo pull request, the agent’s bash can run the PR’s own code with your provider key in its environment, so keep this job for branches pushed by people you already trust with that key.
One provider key, one pinned release, one command - and you pick the model per job the same way you would anywhere else in OpenCode. --model takes a provider/model string (CLI documentation, as of October 2026); here it comes from a repository variable, OPENCODE_MODEL, set to a value such as anthropic/claude-sonnet-4-20250514, the form the GitHub integration documentation’s example uses. Point it at a cheap fast model for routine diff review, and reserve a stronger one for the runs that touch the dedup core; that split is where this job’s cost is decided.
The wired checks matter more here than they ever did locally. On your laptop you would have caught the type error yourself; on a runner, the checks wired into the job are the only thing standing between “the agent says it passed” and it being true. An unattended run’s done is a claim, and each verifier you wire into the run converts one class of silent failure into a caught one - at a cost, and with a ceiling. Watch what each layer buys:
Mapped back to the review job: go vet and go test are two verifiers, each a job step whose exit code sets the check status, and the posted review comment is the handoff to a human read, not a third check. And the residual the scene ends on is real - no machine check catches a requirement nobody wrote down; the misread ticket ships green through every layer. That is why this workflow posts a review for a human to read rather than merging on its own: the wired checks can earn “the code does what the checks say,” and the morning read of the diff still owns the claims nobody encoded.
Let OpenCode act on the repo directly
Section titled “Let OpenCode act on the repo directly”Wrapping OpenCode in your own CI script is the general escape hatch. On GitHub you usually don’t need it - OpenCode’s GitHub integration lets the agent work in your issues and pull requests instead of being a step you wire by hand. GitLab has a path too, but a less polished one (more on that below).
On GitHub the fastest path is the guided installer:
$ opencode github installIt walks you through adding the GitHub App and drops a workflow at .github/workflows/opencode.yml. After that the agent lives in your issues and PRs. Mention /opencode (or the short /oc) in a comment and it runs in your repository’s GitHub Actions runner, not in an OpenCode-hosted service. Your model provider still receives the code the agent reads:
# as a comment on a feedmill issue/oc the JSON Feed parser drops items with no published date. figure out why and open a PR with a fix and a test.OpenCode reads the issue, works the problem in the runner, and opens a pull request with the change - the full loop, triggered from a comment instead of your terminal. The workflow the installer writes triggers only on comments (issue_comment and pull_request_review_comment; github.handler.ts at v1.18.32), so the every-PR review needs a pull_request trigger added to it. On a pull_request event with no prompt, the integration defaults to reviewing the PR. The triggers, the default-to-review behavior, and the permissions below are as documented (GitHub integration documentation, as of October 2026).
The permissions depend on which token writes. In the default OIDC setup (OIDC, OpenID Connect, is how the workflow proves its identity), the action exchanges an OIDC token for an OpenCode GitHub App installation token and writes with that, so the workflow needs only id-token: write. With use_github_token: true, the action writes with the GITHUB_TOKEN you pass instead, so grant that token contents: write and pull-requests: write to create branches and PRs, and issues: write to comment on issues; id-token: write is then not needed.
GitLab is supported but not symmetric - there’s no opencode gitlab install to mirror the GitHub installer. Instead you wire it into your pipeline by including a CI/CD component in .gitlab-ci.yml (or by going through GitLab Duo):
include: - component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@2The component is published at gitlab.com/nagyv/gitlab-opencode, and @2 is the major the GitLab integration documentation shows as of October 2026; pin whatever major the component’s page shows when you wire it up. And the trigger differs: on GitLab you at-mention @opencode in a comment (configurable to a different phrase) rather than using the /opencode or /oc slash command GitHub uses.
When you’re embedding, reach for ACP
Section titled “When you’re embedding, reach for ACP”A CI script shells out to OpenCode. The GitHub integration lives inside Actions. But if you’re building a tool that hosts OpenCode - an editor, an internal dashboard, your own automation surface - don’t drive the HTTP API by hand. OpenCode speaks ACP, the Agent Client Protocol, and exposes it through opencode acp. ACP is an open protocol for communication between code editors and coding agents; the ACP documentation lists Zed and JetBrains IDEs among the editors that use it (as of October 2026; ACP documentation). It’s the cleanest path for embedding OpenCode in another tool too: you talk a protocol that other agents also implement, instead of binding your tool to OpenCode’s own HTTP API.
You won’t need ACP to get feedmill’s PR reviews running - the GitHub integration and the review workflow cover that. It’s the door you walk through the day feedmill stops being the thing OpenCode reviews and becomes a thing that has OpenCode inside it.
That’s the headless half of OpenCode end to end: a server you can run with no interface, a client you attach when you want to see inside, a container that makes CI reproducible, and the GitHub integration (plus a GitLab path) that lets the agent act on the repo on its own. With unattended runs wired, what’s left is the interactive side: the daily habits that make the loop fast - the daily-workflow chapter.