Python SDK for Opencode — the open source AI coding agent.
pip install opencode-pyDo I need Opencode pre-installed? No. The SDK automatically downloads the
opencode binary for your OS (Windows/macOS/Linux, x64/arm64) on first use to
~/.opencode/bin/. The binary is only used internally by the SDK — it is NOT
added to PATH, NOT registered system-wide, and NOT shown in the Start Menu.
What if I install the official Opencode later? If you install Opencode
via npm install -g opencode-ai or another method, the SDK will use the
PATH version instead — no conflict.
See Binary management for details.
After installation, the opencode-py command is available system-wide from any directory:
opencode-py "What is the capital of France?"# one-shot promptecho"What is the capital of France?"| opencode-py # via pipe
opencode-py --help # show all optionsAll CLI flags:
| Flag | Description |
|---|---|
prompt (positional) | Prompt text or read from stdin |
--model / -m | Model name (e.g. opencode/big-pickle) |
--keep / -k | Keep session alive between calls |
--auto-tools | Enable agentic tool execution |
--directory / -d | Working directory |
--port / -p | Server port (default: auto — first free port) |
You can also use python -m opencode:
python -m opencode "Explain dependency injection"
python -m opencode --model "opencode/big-pickle""Hello"fromopencodeimportopencodeanswer=opencode("What is the capital of France?")
print(answer)fromopencodeimportOpencodewithOpencode() asai:
answer=ai.ask("Explain dependency injection")
print(answer)withOpencode() asai:
forchunkinai.ask_stream("Write a Python function"):
print(chunk, end="")ask_stream() subscribes to the server's SSE (/event) endpoint, sends the
prompt, and yields each text chunk as it arrives. Reasoning blocks, user echo,
and duplicate text are automatically filtered out.
When called with collect=True, ask_stream() returns a StreamResult
wrapper (or AsyncStreamResult for async) that exposes .events and .text
after iteration:
withOpencode() asai:
stream=ai.ask_stream("Write a function", collect=True)
forchunkinstream:
print(chunk, end="")
# After consumption:print(stream.text) # full response textprint(stream.events) # all raw SSE eventsV2 Session.prompt() also uses the /event SSE endpoint internally —
it sends a non-blocking V2 prompt, subscribes to events, and waits for
session.next.step.ended before assembling the response. V1 blocking prompt
is used as a fallback when model or format is specified.
All high-level methods accept collect=True to return an OpendcodeResponse
dataclass containing both the response text and all raw SSE events:
fromopencodeimportopencode# Get text + raw eventsresponse=opencode("Hello", collect=True)
print(response.text) # "Hello! How can I help you?"print(response.events) # all SSE events received during the promptfromopencodeimportOpencodewithOpencode() asai:
session=ai.create_session()
result=session.prompt("Say hi", collect=True)
print(result.text) # "Hi!"print(result.events) # [StreamEvent, ...] — full event logWorks with: Session.prompt(), Session.ask(), Opencode.ask(),
opencode(), async_opencode(), ask_stream(collect=True), and their
async counterparts.
For advanced use, the SDK exposes the full SSE event stream as typed Pydantic
models via parse_stream_event():
fromopencode._stream_eventsimport (
MessagePartDeltaProps,
MessagePartUpdatedProps,
MessageUpdatedProps,
SessionStatusProps,
parse_stream_event,
)
withOpencode() asai:
session=ai.create_session()
response=ai.client.event_subscribe() # raw SSE streamai.client.session_send(session.id, {"parts": [{"type": "text", "text": "Hi"}]})
forlineinresponse.iter_lines():
ifnotline.startswith("data: "):
continueevent=parse_stream_event(line[6:])
props=event.properties# Skip events for other sessionsifprops.get("sessionID") notin (None, session.id):
continueifevent.type=="message.part.delta":
p=MessagePartDeltaProps.model_construct(**props)
print(p.delta, end="") # typed access to .delta, .partID, etc.elifevent.type=="session.status":
p=SessionStatusProps.model_construct(**props)
ifp.status.get("type") =="idle":
breakThis works for all ~75 event types: message.updated, session.status,
session.next.text.delta, permission.asked, question.asked, file.edited,
and more.
Note: The example above shows V1 blocking prompt (
session_send). V2Session.prompt()internally usessession.next.*events (session.next.prompted,session.next.step.ended, etc.) delivered through the same/eventSSE endpoint.
Use parse_typed_event() for automatic property validation. See
live_stream_events.py for a complete demo.
withOpencode() asai:
session=ai.create_session()
msg1=session.prompt("Suggest a project name")
print(f"AI: {msg1}")
msg2=session.prompt("Now write a tagline for it")
print(f"AI: {msg2}")Session.prompt() uses V2 non-blocking prompt + SSE subscription for
faster responses. Falls back to V1 blocking prompt when model or
format is specified (e.g. structured output).
Every Session object provides additional methods:
withOpencode() asai:
session=ai.create_session()
session.prompt("Hello")
# Get conversation historyctx=session.context() # list of all messagesmsgs=session.messages() # paginated message list# Controlsession.abort() # abort current generationsession.delete_message("msg_xxx") # permanently remove a messagesession.compact() # compact conversationsession.fork() # fork into new session# Inspectsession.diff() # file changes made by AIsession.todo() # remaining TODOsMessage deletion is permanent — the message and its parts are removed without reverting file changes. To undo changes made by a message, use session.revert() instead (or session.fork() to branch).
Reuses server and session across calls:
fromopencodeimportopencoder1=opencode("My name is Alice", keep=True)
r2=opencode("What's my name?", keep=True) # remembers conversationr3=opencode("That's all", keep=False) # closes server# Also accepts: model, format, port, directory, config, agentr=opencode("Create a file called hello.txt", auto_tools=True)Available tools: bash, write, edit, read, glob, grep.
By default bash asks for permission in the console, all others run without prompting.
Custom permissions via Session.ask():
fromopencodeimportOpencode, ToolExecutorwithOpencode() asai:
session=ai.create_session()
msg=session.ask(
"Write test.py with print('hello')",
tool_executor=ToolExecutor(
permissions={"write": "allow"},
workdir="/path/to/sandbox", # restrict file operations
),
max_tool_rounds=25, # safety limitquiet=True, # suppress tool logs
)The first AI response in ask() enters plan mode — the SDK auto-confirms with
"Exit plan mode and proceed" to make the model execute tools immediately.
withOpencode() asai:
content=ai.client.file_read("src/main.py")
diff=ai.client.vcs_diff("HEAD~3")
config=ai.client.config_get()
session=ai.client.session_create()
ai.client.v2_session_prompt(session.id, {"text": "Hello"})All client methods return typed Pydantic models — IDE autocomplete,
.model_dump(), .model_dump_json().
Skip subprocess management by pointing at a running opencode serve:
fromopencodeimportOpencodeClientclient=OpencodeClient(base_url="http://127.0.0.1:4096", directory=".")
health=client.health()fromopencodeimportAsyncOpendcodeClientasyncwithAsyncOpendcodeClient(base_url="http://127.0.0.1:4096") asclient:
health=awaitclient.health()client2=client.copy(base_url="http://other:4096", timeout=60.0)
# Or via with_options:faster=client.with_options(timeout=10.0, max_retries=0)Wraps any client method to also return the raw httpx.Response:
fromopencodeimportRawResponsewithclient.with_raw_response:
raw: RawResponse=client.health()
raw.status_code# 200raw.headers# httpx.Headersraw.content# bytesraw.parsed# HealthResponse (typed model)raw.response# httpx.Response (full)The context manager resets automatically after one call. Works with every
client method (sync and async). See live_raw.py for a full demo.
Typed exception hierarchy. All errors are importable from opencode:
fromopencodeimportOpencodeClient, RateLimitError, InternalServerErrorclient=OpencodeClient(max_retries=3) # exponential backoff with jittertry:
health=client.health()
print(health.version)
exceptRateLimitError:
print("too many requests — retried but failed")
exceptInternalServerError:
print("server error")Full error class hierarchy:
| Class | HTTP status | When raised |
|---|---|---|
OpencodeError | — | Base for all SDK errors |
APIConnectionError | — | Network / connection failure |
APITimeoutError | — | Request timed out |
APIResponseValidationError | — | Response doesn't match schema |
APIStatusError | 4xx/5xx | Base for HTTP error responses |
BadRequestError | 400 | Malformed request |
AuthenticationError | 401 | Invalid or missing API key |
PermissionDeniedError | 403 | Access denied |
NotFoundError | 404 | Resource not found |
ConflictError | 409 | Resource conflict |
UnprocessableEntityError | 422 | Validation error in request body |
RateLimitError | 429 | Rate limit exceeded |
InternalServerError | 500+ | Server-side error |
BinaryNotFoundError | — | opencode binary not on PATH |
ServerStartupTimeoutError | — | Server didn't start in time |
Retry policy: 408, 409, 429, 5xx and timeouts are retried with exponential
backoff + jitter. Retry-After and retry-after-ms headers are respected.
withOpencode(model="anthropic/claude-sonnet-4") asai:
result=ai.ask(
"Generate a user profile",
format={
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
},
},
)
# result is a JSON string matching the schemaWorks with opencode(), async_opencode(), Session.prompt(), and Session.ask().
Requires a model that supports tool_choice="required" (Claude, GPT-4).
The free opencode/big-pickle (DeepSeek) does NOT support this.
# Linux / macOS (bash/zsh)
OPENCODE_LOG=debug python my_script.py
# Windows (PowerShell)$env:OPENCODE_LOG="debug"; python my_script.py
# Windows (cmd)set OPENCODE_LOG=debug && python my_script.pyShows all HTTP requests/responses with timing.
python web/server.py
# → open http://127.0.0.1:3000Built-in HTTP server + proxy to opencode serve — no extra dependencies.
python live.py # sync multi-turn dialog
python live_async.py # async multi-turn dialog
python live_streaming.py # streaming dialog (reuse session)
python live_raw.py # with_raw_response demo (7 scenarios)
python live_stream_events.py "Your prompt"# typed SSE event inspection
python demo.py # full API coverage test (38 endpoints)All scripts clean up the server on exit via atexit.
fromopencodeimportToolExecutor# Default permissions:# bash → "ask" (prompts in console)# write → "allow"# edit → "allow"# read → "allow"# glob → "allow"# grep → "allow"executor=ToolExecutor(
permissions={
"bash": "allow", # always allow"write": "deny", # always deny"grep": "ask", # ask each time
},
workdir="/path/to/sandbox", # restrict file operations hereconfirm=lambdaname, inp: name!="bash", # custom confirm function
)
# Use with Session.ask():session.ask("Create a project", tool_executor=executor)When opencode is not on PATH, the SDK auto-downloads it to
~/.opencode/bin/opencode.
Resolution order:
PATH—shutil.which("opencode")~/.opencode/bin/opencode— previously downloaded copy- GitHub releases — download for current platform
Supported platforms: win32-x64, win32-arm64, darwin-x64, darwin-arm64,
linux-x64, linux-arm64.
Override the binary path directly:
withOpencode(opencode_binary="/custom/path/opencode") asai:
...fromopencodeimportOpencodeServer, create_opencode_serverserver=create_opencode_server(
port=4096,
hostname="127.0.0.1",
timeout=30.0,
config={"model": "opencode/big-pickle"},
opencode_binary="/path/to/opencode",
)
print(server.url) # "http://127.0.0.1:4096"# Later:server.close() # kills the subprocessAll parameters for Opendcode() / AsyncOpendcode():
| Parameter | Default | Description |
|---|---|---|
model | None | Model name, e.g. "opencode/big-pickle" or "provider/model" |
hostname | "127.0.0.1" | Bind address for the server |
port | None (auto) | Port for the server; None picks first free port |
directory | None | Working directory passed to all API calls |
workspace | None | Workspace directory for the session |
server_timeout | 30.0 | Seconds to wait for server startup |
client_timeout | 300.0 | Seconds before HTTP request timeout |
config | None | Server config dict (see opencode docs) |
opencode_binary | None | Path to opencode binary (auto-downloaded if not set) |
All parameters are keyword-only.
importasynciofromopencodeimportAsyncOpendcodeasyncdefmain():
asyncwithAsyncOpendcode() asai:
answer=awaitai.ask("Explain async/await in Python")
print(answer)
asyncio.run(main())asyncwithAsyncOpendcode() asai:
asyncforchunkinai.ask_stream("Write a poem"):
print(chunk, end="")Async streaming also supports collect:
asyncwithAsyncOpendcode() asai:
stream=ai.ask_stream("Write a poem", collect=True)
asyncforchunkinstream:
print(chunk, end="")
print(stream.events) # raw SSE eventsprint(stream.text) # full response textasyncwithAsyncOpendcode() asai:
session=awaitai.create_session()
msg1=awaitsession.prompt("Suggest a project name")
msg2=awaitsession.prompt("Now write a tagline for it")fromopencodeimportAsyncOpendcodeClientasyncwithAsyncOpendcodeClient() asclient:
health=awaitclient.health()
print(health.version) # typed Pydantic modelfromopencodeimportasync_opencoderesult=awaitasync_opencode("Hello", keep=True)
result2=awaitasync_opencode("Still there?", keep=True)
result3=awaitasync_opencode("Bye")
# Also accepts: model, format, port, directory, config, agent, auto_toolsfromopencodeimportOpencodeResponseresponse=session.prompt("Hello", collect=True)
# response.text -> str (extracted response text)# response.events -> list[StreamEvent] (raw SSE events)Returned by Session.prompt(), Session.ask(), Opencode.ask(),
opencode(), async_opencode(), and their async counterparts when
collect=True.
fromopencodeimportStreamResult, AsyncStreamResultstream=ai.ask_stream("Hello", collect=True)
# for chunk in stream: — iterate text chunks# stream.events -> list[StreamEvent] (after iteration)# stream.text -> str (full response text)StreamResult (sync) and AsyncStreamResult (async) wrap the
ask_stream(collect=True) iteration, collecting all SSE events for
later inspection.
fromopencode._response_modelsimportHealthResponse, SessionResponse, FileContentResponse# These are Pydantic BaseModel classes with:# .model_dump() -> dict# .model_dump_json() -> str# .model_validate(dict) -> classmethod# Install in editable mode
pip install -e ".[dev]"# Run tests
pytest
# Lint & typecheck
ruff check src/
mypy src/
# Build
python -m build --wheel