motif64

Agents · DeepSeek V4.1 Flash · OpenRouter · CPU/API

published by @dh7 on

m64 m64_a276407d7180
Download .tar.gz

opencode agent on DeepSeek V4.1 Flash (OpenRouter). opencode 1.18.23 coding agent driving DeepSeek V4.1 Flash through OpenRouter. Each session gets an empty directory; the agent can write and run code there. No GPU; the only egress is openrouter.ai:443, and the m64 gate adds the OpenRouter key, so the agent never holds it.

Lineage

parent
none declared
derived from
no published capsule shares half its files
children
none

Files

8 files

AGENT.md

opencode coding agent (DeepSeek V4.1 Flash via OpenRouter)

This service is a coding agent you drive over HTTP. You open a session, send it a task in plain English, and it does the work: it writes files, runs shell commands and reads the results until it is done. Then it replies. The harness is opencode 1.18.23. The model is DeepSeek V4.1 Flash, called through OpenRouter.

Each session works in its own empty directory, and you can list, download and upload its files. The agent runs in a sandbox. It is not root, and it reaches only what the user granted when serving it. The model is on OpenRouter, so it runs with --allow-external-network: the public internet, never the LAN or tailnet (add --allow-internal-network for those). network_access in GET /status and sandbox.network in GET /agent?format=json say what this run has; every connection is logged. Python 3.11's standard library, node 22, git, ripgrep, jq and a C toolchain are installed.

You cannot choose the model, tools or system prompt. You send only message text and files.

Security note

  • The agent cannot read the OpenRouter key, but anyone who can reach this service can spend it. The key never enters the sandbox: the m64 gate adds it to the agent's requests to openrouter.ai, and the agent only sees a placeholder. Every task you send still costs OpenRouter credit on that key, so whoever holds this service's token can spend it. Keep the token private.
  • Sessions are not isolated from each other. Every session runs as the same user, in the same container, so an agent in one session can read and change other sessions' files. Use one running service per person or project, not per session.

Base URL: {{BASE_URL}}

The full opencode interface, and opencode attach

This service also exposes opencode's own server on a second port, the app port: its complete web UI and its whole HTTP API (SSE events and the terminal's WebSocket included). Its URL is app_url in GET /status and GET /agent?format=json; it is usually this service's port + 1. /human/ links to it.

From any machine that can reach it (your tailnet by default), install opencode 1.18.23 (npm i -g opencode-ai@1.18.23) and run the local TUI against this sandbox:

opencode attach <app_url>   # with a token: OPENCODE_SERVER_PASSWORD=<token> opencode attach <app_url>

Files, shell commands and model calls all run in this service's container; the OpenRouter key stays in the m64 gate, never in the container or on your machine.

  • The app port has no per-session isolation: it sees every session and file, and its project is the run's whole workspace (/scratch/work).
  • It has no params filtering: it is opencode's raw API, shell and terminal included, which is what the agent can already do inside its container.

Quick start

SID=$(curl -s -X POST {{BASE_URL}}/v1/sessions {{AUTH}} \
  -H "Content-Type: application/json" -d '{"title": "demo"}' | jq -r .id)

curl -s -X POST {{BASE_URL}}/v1/sessions/$SID/messages {{AUTH}} \
  -H "Content-Type: application/json" \
  -d '{"text": "write hello.py that prints hi and run it"}' | jq '{text, steps, elapsed_s, tools: [.tools[] | {tool, status}]}'

curl -s {{BASE_URL}}/v1/sessions/$SID/files {{AUTH}}
curl -s "{{BASE_URL}}/v1/sessions/$SID/files?path=hello.py" {{AUTH}}

Routes

POST /v1/sessions

Creates a session with a fresh empty directory. The body is optional: {"title": "..."}. Returns 201 with {"id": "ses_...", "title": ..., "created": <unix time>}.

POST /v1/sessions/{id}/messages

Sends one task or follow-up to the session. The agent remembers earlier messages in the same session.

  • text (string, required): what to do.
  • wait (bool, default true): with true, the request blocks until the agent's turn is over. That can take a few seconds or several minutes, so set your client timeout to at least 10 minutes. With false, it returns 202 at once; follow the turn on the events stream.

Reply (with wait: true):

{"id": "msg_...", "role": "assistant", "text": "Created hello.py ... prints: hi",
 "tools": [{"tool": "write", "status": "completed", "input": {...}, "output": "..."},
           {"tool": "bash", "status": "completed", "input": {"command": "python3 hello.py"}, "output": "hi\n"}],
 "steps": 3, "tokens": {...}, "finish": "stop", "elapsed_s": 6.2}

tools lists every tool call in the turn, and each output is cut to 4000 characters. When the model call itself fails (for example, OpenRouter is out of credit), the reply carries an error object.

GET /v1/sessions/{id}/events

Server-sent events for this session: opencode's own event objects (message parts as they stream, tool calls, session.idle when a turn ends), one data: line each. The stream stays open; close it when you are done.

curl -sN {{BASE_URL}}/v1/sessions/$SID/events {{AUTH}}

GET /v1/sessions/{id}/files[?path=...]

With no path, or a directory path, it lists entries: {"path": ".", "entries": [{"name": "hello.py", "type": "file", "size": 12}]}. type is file, dir or other (a FIFO, socket or device).

A file path returns the file's bytes as a download (Content-Disposition: attachment). The agent wrote these bytes, so they are never served as a web page: the type is text/plain, image/png, image/jpeg, image/gif or image/webp when the extension says so, and application/octet-stream for everything else (.py, .html, .svg...). A path that leaves the session directory, contains a NUL byte, or names something other than a regular file or directory returns 400. A missing path returns 404.

PUT /v1/sessions/{id}/files?path=...

Uploads the raw request body to path in the session directory, creating parent directories as needed. 400 if path exists and is not a regular file. Returns 201 with {"written": "data/input.csv", "bytes": 1234}.

curl -s -X PUT "{{BASE_URL}}/v1/sessions/$SID/files?path=data/input.csv" {{AUTH}} \
  --data-binary @input.csv

POST /v1/sessions/{id}/abort

Stops the session's running turn. A messages request waiting on that turn then returns early.

GET /v1/agent

What this agent is: harness, model and sandbox policy.

GET /status

Start-up progress: phase is building, starting, loading, ready or error. network summarises the sandbox's outbound connections, and lists blocked attempts.

Timings and concurrency

  • First start builds the container image, which takes a few minutes. After that, the service is ready in about 5 s (budgeted at 60 s).
  • A small task such as the quick start takes about 3–10 s, and bigger tasks take minutes. Time depends on OpenRouter and on how many steps the agent takes.
  • Sessions are independent and can run in parallel. Send one message at a time per session, and wait for the turn to end before you send the next.
  • Sessions and their files live only as long as this run of the service. Download what you want to keep.

Errors

  • 401: missing or wrong token. Send Authorization: Bearer <token>.
  • 503 with Retry-After while the service is still starting: wait and retry.
  • 404: unknown session id, file not found, or a route this service does not offer.
  • 400: the body is not a JSON object, text is missing, or a path escapes the session.
  • 5xx otherwise: retry once, then check GET /status.

capsule.toml

schema = "m64/capsule@1"description = "opencode agent on DeepSeek V4.1 Flash (OpenRouter). opencode 1.18.23 coding agent driving DeepSeek V4.1 Flash through OpenRouter. Each session gets an empty directory; the agent can write and run code there. No GPU; the only egress is openrouter.ai:443, and the m64 gate adds the OpenRouter key, so the agent never holds it."kinds = ["agent.session"]authors = ["dh7"]license = "apache-2.0"[hardware]platform = "linux-aarch64"[runtime]sandbox = "container"# No image: the Dockerfile builds on node:22-bookworm-slim pinned by digest.gpu = falseinternal_port = 9500health = "/health"expected_load_seconds = 60user = "1000:1000"# opencode's own server (web UI + full API, SSE and the terminal's WebSocket) on a second# front port. Raw: no per-session isolation and no params filtering there.[runtime.app]internal_port = 4096[network]needs = ["openrouter.ai:443"]why = "the model runs on OpenRouter: your prompts and its replies go through openrouter.ai"[secrets]required = ["OPENROUTER_API_KEY"]# The key never enters the container: opencode calls http://openrouter.ai/api/v1 through# the gate, which adds the header and goes on over TLS. The capsule's env var holds only the# placeholder m64-injected:OPENROUTER_API_KEY.[[secrets.inject]]name = "OPENROUTER_API_KEY"host = "openrouter.ai"header = "Authorization"value = "Bearer {secret}"