Skip to content
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
<a href="https://peps.python.org/pep-0561/"><img alt="Typed" src="https://img.shields.io/badge/typed-py.typed-2b5b84"></a>
<a href="https://claude.com/claude-code"><img alt="Claude harness" src="https://img.shields.io/badge/harness-Claude-D97757?logo=anthropic&logoColor=white"></a>
<a href="https://openai.com/codex/"><img alt="Codex harness" src="https://img.shields.io/badge/harness-Codex-4B68F9?logo=openai&logoColor=white"></a>
<a href="https://opencode.ai/"><img alt="OpenCode harness" src="https://img.shields.io/badge/harness-OpenCode-2f855a"></a>
<a href="https://github.com/AlmanacCode/Yoke/blob/main/LICENSE.md"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-df7b40"></a>
</p>

**A Python SDK for building agents on Claude Code and Codex.**
**A Python SDK for building agents on Claude Code, Codex, and OpenCode.**

Claude Code and Codex have become general-purpose agents: give them
instructions, skills, and subagents, and they can be shaped to any task. Yoke
lets you reuse them from code — one `Harness` that drives both.
Claude Code, Codex, and OpenCode have become general-purpose agents: give
them instructions, skills, and subagents, and they can be shaped to any
task. Yoke lets you reuse them from code — one `Harness` that drives all
three.

<p align="center">
<a href="#quickstart">Quickstart</a> ·
Expand All @@ -42,6 +44,9 @@ Install a provider extra when you want Yoke to manage that SDK directly:
pip install 'almanac-yoke[claude]' # or [codex], or [all]
```

OpenCode needs no extra — it has no Python SDK to install; Yoke drives it
by spawning `opencode serve` and talking to its HTTP API directly.

Define an agent, pick a harness, run:

```python
Expand All @@ -60,7 +65,9 @@ print(result.output)
```

Swap `"codex"` for `"claude"` and the same agent runs there. Your existing
Claude Code or ChatGPT login is all it needs — no API keys.
Claude Code or ChatGPT login is all it needs — no API keys. Swap it for
`"opencode"` and Yoke drives whatever provider your `opencode auth login`
(or a configured API key) already has set up.

Embedding applications can observe a one-shot run while it is happening:

Expand Down Expand Up @@ -258,6 +265,7 @@ require an exact one:
| `codex:sdk` | Codex Python SDK |
| `codex:cli` | Codex CLI — `codex exec`, resumable threads |
| `claude:sdk` | Claude Agent SDK for Python |
| `opencode:server` | OpenCode's local HTTP server — sessions, fork, native skills |

`discover` reports what this machine already has — surfaces installed, logins
ready, models available — and picks the first ready surface satisfying the
Expand Down
42 changes: 42 additions & 0 deletions almanac/concepts/provider-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ sources:
- id: capability-tests
type: file
path: tests/test_capabilities.py
- id: opencode-server
type: file
path: src/yoke/providers/opencode_server.py
- id: opencode-plan
type: file
path: docs/plans/2026-07-11-opencode-provider.md
- id: opencode-permissions
type: file
path: src/yoke/providers/opencode/permissions.py
- id: opencode-hooks
type: file
path: src/yoke/providers/opencode/hooks.py
- id: opencode-agents
type: file
path: src/yoke/providers/opencode/agents.py
- id: options
type: file
path: src/yoke/options.py
---

Provider surfaces are the concrete Claude or Codex entrypoints that Yoke plans against. A provider is the family, such as `codex` or `claude`; a surface is the actual exposure path, such as `codex_app_server`, `codex_python_sdk`, `codex_cli`, or `claude_python_sdk` [@models]. Surfaces matter because Yoke treats features as surface-specific, not provider-wide [@decision].
Expand Down Expand Up @@ -52,3 +70,27 @@ Run event callbacks show the planning rule in a small form. `RunOptions(on_event
Provider surfaces are not a cosmetic naming scheme. They control whether a [Yoke Harness](yoke-harness) may run, stream, resume, fork, use workflows, expose request callbacks, or report a feature as unavailable. The capability matrix answers what the surface can do before the provider turn starts [@surfaces].

This is why the decision note calls provider surfaces first-class. A generic provider default is acceptable for simple use, but advanced behavior must resolve to a concrete surface before Yoke claims that a feature exists [@decision].

## OpenCode: a third provider with no Python SDK

`opencode_server` is Yoke's third provider surface, alongside Claude and Codex [@models]. Unlike either of those, OpenCode ships no Python SDK — the adapter spawns `opencode serve --port 0` as a child process and drives it entirely over its documented HTTP API [@opencode-server]. That makes it closer in shape to `codex_app_server` (a locally spawned, long-lived process) than to a Python-SDK surface, and it reuses the same design precedent: the process/HTTP/polling mechanics stay synchronous and thread-backed, and the async `ProviderAdapter` methods bridge to them with `asyncio.to_thread` rather than a from-scratch asyncio rewrite [@opencode-server].

OpenCode's own SSE event stream was found unreliable for live progress narration in a prior live spike, so this adapter instead polls OpenCode's own SQLite database for new session parts while a turn is in flight, and uses the same polling loop to detect a tool call stuck past a threshold — a confirmed upstream OpenCode reliability gap, not a Yoke bug [@opencode-plan]. SQLite is only used for this in-flight progress poll; stored session history (`read_session()`) instead calls the documented `GET /session/:id/message` endpoint, sliced locally for offset/limit since OpenCode's own `limit` keeps the most recent N messages rather than the earliest N [@opencode-server].

Sessions are first-class on this surface, not a one-shot-only wrapper — OpenCode's HTTP API supports real `GET /session`, `PATCH /session/:id`, `POST /session/:id/fork`, and `POST /session/:id/summarize` endpoints, so `start`/`send`/`close` map onto genuine multi-turn sessions and `run()` is a thin convenience wrapper over them [@opencode-server]. A forked session shares its parent's underlying server process rather than spawning a new one, so process termination is reference-counted the same way `CodexAppServer` reference-counts its shared app-server process, rather than tied to whichever session happens to close first [@opencode-server]. Confirmed live: `POST /session/:id/fork` does not inherit the parent's permission ruleset — a fork starts with none at all (default allow) regardless of how restrictive the parent session was — so `fork()` re-applies it via `PATCH /session/:id` right after forking [@opencode-server].

### Skills, subagents, and MCP: config-file compilation

OpenCode discovers skills, custom agents, and MCP servers from files and config, not a runtime registration API, so this adapter compiles Yoke's declarative model into that shape once per session rather than issuing calls during a turn. Inline skills and direct Yoke subagents render as `skills/<name>/SKILL.md` and `agents/<name>.md` (YAML frontmatter, `mode: subagent`) under a Yoke-owned deployment directory pointed at by `OPENCODE_CONFIG_DIR`, so nothing lands in the user's real project [@opencode-agents] [@opencode-server]. Direct subagents only — OpenCode documents no nested subagent-of-subagent invocation model to compile a recursive one against [@opencode-agents]. `agent.options["mcp_servers"]` renders as `{"mcp": {...}}` JSON passed through `OPENCODE_CONFIG_CONTENT`, OpenCode's highest-precedence config source, since there is no runtime add-server endpoint to call instead [@opencode-server].

Each subagent's own `permissions.access`/`.network` also compiles into a `permission:` frontmatter key on its `agents/<name>.md` file — confirmed live that a per-agent `bash: deny` genuinely blocks the tool for that agent specifically, independent of whatever the parent session's own ruleset allows. This mirrors `codex_agent_toml()`'s existing `sandbox_mode(agent.permissions)` translation for Codex subagents; a subagent left at the default `Permissions()` (access=READ) already got Codex's read-only sandbox, so OpenCode's default-permissions subagents now get the equivalent restriction rather than inheriting an unrestricted session [@opencode-agents].

### Live permission approval: polling, not SSE, and not the endpoint you'd expect

The original plan assumed pending permissions were "only learnable via SSE" and shipped an always-allow-all session, declaring `PERMISSIONS`/`REQUEST_EVENTS` `compiled`/`unsupported` [@opencode-plan]. That assumption was wrong: `GET /permission` is a real, non-deprecated, polling-discoverable endpoint listing every pending permission across sessions, confirmed live against a real `opencode serve` process — the same poll-not-SSE shape as the DB-poll progress watchdog, just polling OpenCode's HTTP API instead of its SQLite database [@opencode-permissions]. `Permissions.approval=ASK` now passes an ask-all session permission block instead of allow-all, and a dedicated `OpencodePermissionWatchdog` runs on its own thread alongside the progress watchdog, resolving each pending permission through `ProviderOptions.opencode.request_handler`/`.policy` — the same `RequestPolicy`/`Response` contract Claude and Codex app-server already use for their own request callbacks — and replying via `POST /permission/:id/reply` [@opencode-permissions] [@options]. The endpoint this adapter used to target for replies, `/session/:id/permissions/:permissionID`, turned out to be deprecated in OpenCode's own OpenAPI document; the reply now goes through the current one [@opencode-permissions].

`Permissions.access`/`.network` are translated the same way `accessible_claude_tools()` gates Claude's own tool list: write/edit/apply_patch need WRITE or FULL, bash needs FULL, webfetch/websearch need `network`. An earlier version of this session-permission block only translated `approval`, so `Permissions(access=READ, network=False, approval=NEVER)` produced a bare `* = allow` rule and every tool actually ran despite the session reporting a read-only, no-network posture — confirmed live and fixed [@opencode-server].

### Plugins and hooks: a generated bridge, not just config

Plugins are OpenCode's one genuinely executable extension point (JS/TS auto-loaded from the same `OPENCODE_CONFIG_DIR` used for skills/agents), which splits into two very different features here. `agent.options["opencode_plugins"]` (name → raw JS source) is pure pass-through — Yoke writes whatever the caller supplies into `plugin/<name>.js` and does not generate or validate it, the same shape as MCP config [@opencode-server]. Hooks are the opposite: Yoke *generates* a `tool.execute.before` plugin that relays every tool call to a small local HTTP server this adapter starts (`OpencodeHookBridge`), which resolves the call through the same `request_handler`/`.policy` contract permissions use and replies with a decision [@opencode-hooks]. Confirmed live: mutating the reply's `args` actually changes the command OpenCode executes, and denying actually blocks the tool call with a graceful message back to the model — not just an observed-after-the-fact event [@opencode-hooks]. The bridge is opt-in (no configured handler means no plugin file and no server) and process-scoped rather than session-scoped: it's addressed by an env var fixed at `opencode serve` spawn time, and a fork shares its parent's process and env, so one bridge serves every session on that process, routing by the `sessionID` present in each tool call's own payload [@opencode-hooks].
Loading