HTTP API
The wire format SoyaOS speaks — OpenAI-compatible chat completions plus the Scope event channel.
A Moon exposes one HTTP surface that’s deliberately small and OpenAI-shaped: clients that already speak /v1/chat/completions work without code changes. The Scope event stream rides alongside on a parallel channel for clients that want it.
Everything below is stable within v0; new endpoints will be added, never re-shaped.
Base URL
https://moon.example.com/v1
For solo (laptop) deployments: http://127.0.0.1:7474/v1.
Authentication
Authorization: Bearer <api_key>. Mint keys with soyaos auth keys create.
Solo: no auth required. The Moon refuses any non-loopback bind on solo to keep this honest.
POST /v1/chat/completions
OpenAI-compatible chat completions. The body shape matches OpenAI almost exactly; SoyaOS-specific extensions live under soya: prefixed fields and are silently ignored by upstream-compatible clients.
Request
{
"model": "soya:compo",
"messages": [
{ "role": "user", "content": "outline an essay about X" }
],
"stream": true,
"soya": {
"input": { "topic": "X", "tone": "academic" },
"pin_version": "1.4.0",
"cache_mode": "rw"
}
}
| Field | Required | Notes |
|---|---|---|
model | yes | soya:<name>[@<version>] or a pass-through name (claude-…, gpt-…). |
messages | yes | Standard OpenAI shape. The last user message is the canonical input. |
stream | no | Default false. Server-Sent Events when true. |
temperature | no | Passed through to the upstream LLM if model is a pass-through. |
soya.input | no | Typed input matching the SoyaPack’s inputs schema. If present, takes precedence over messages. |
soya.pin_version | no | Pin to a specific SoyaPack version (overrides @version in model). |
soya.cache_mode | no | off / read / write / rw. Default rw. |
Response (non-streaming)
{
"id": "run_018f3a4b1c2d3e4f",
"object": "chat.completion",
"created": 1715670703,
"model": "soya:[email protected]",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "{ \"summary\": { ... } }"
},
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 1842, "completion_tokens": 612, "total_tokens": 2454 }
}
The content is the Agent’s final artifact JSON — same thing you’d see at the bottom of soyaos run. Clients that just want plain text can configure the Agent to emit a string artifact.
Response (streaming, stream: true)
Server-Sent Events, two channels multiplexed by event::
event: message
data: { "id":"…","choices":[{"delta":{"content":"…"}}] }
event: scope
data: { "kind":"stage_started","run_id":"…","stage":"outline","ts":"…" }
event: scope
data: { "kind":"tool_called","run_id":"…","tool":"fetch","args":{…},"ts":"…" }
event: message
data: { "id":"…","choices":[{"delta":{"content":"…"}}] }
event: done
data: [DONE]
Clients that only know OpenAI ignore event: scope lines and see a normal OpenAI stream. Clients that want observability subscribe to both — every Scope event listed in Scope events appears here as it happens.
GET /v1/models
{
"object": "list",
"data": [
{ "id": "soya:compo", "object": "model", "owned_by": "soya", "soya": { "kind": "Agent", "version": "1.4.0" } },
{ "id": "soya:newsbeam", "object": "model", "owned_by": "soya", "soya": { "kind": "Agent", "version": "0.7.2" } },
{ "id": "claude-sonnet-4-6","object": "model", "owned_by": "anthropic" }
]
}
OpenAI-shaped; the soya: extension fields are extra. Pass-through model names appear here if the Moon is configured to advertise them.
GET /v1/runs/{run_id}
Fetch the metadata for a completed run.
{
"run_id": "run_018f3a4b1c2d3e4f",
"pack": "soya:[email protected]",
"tenant": "acme",
"started": "2026-05-14T08:11:43.512Z",
"completed":"2026-05-14T08:11:48.731Z",
"ok": true,
"artifacts":[
{ "name": "outline", "schema": "outline.v1", "url": "https://…/outline.json" },
{ "name": "guide", "schema": "guide.v1", "url": "https://…/guide.json" }
],
"cost": { "comet_seconds": 5.2, "upstream_tokens": 2454 }
}
GET /v1/runs/{run_id}/scope
Replay the Scope event stream for a finished run, as NDJSON:
curl -H "Authorization: Bearer $KEY" \
https://moon.example.com/v1/runs/run_018f3a/scope | jq -c .
{"kind":"run_started","run_id":"run_018f3a4b1c2d3e4f","ts":"…"}
{"kind":"stage_started","run_id":"run_018f3a4b1c2d3e4f","stage":"outline","ts":"…"}
…
{"kind":"run_completed","run_id":"run_018f3a4b1c2d3e4f","ok":true,"ts":"…"}
Use this for “what really happened on run X” investigation. The same events that streamed live are replayable later.
POST /v1/packs (push)
Used by soyaos pack push. Multipart body containing the canonicalized bundle plus the .soya/signature.json. The Moon verifies the signature, allocates an immutable version slot, returns:
{
"pack": "soya:[email protected]",
"bundle_sha256": "8d2c…",
"signed_by": "chzealot-2026",
"status": "published"
}
See Sign and publish a SoyaPack for the full flow.
Error envelope
All errors share this shape:
{
"error": {
"type": "capability_violation",
"message": "egress to api.anthropic.com:443 not in allowlist",
"code": "EGRESS_NOT_ALLOWED",
"run_id": "run_018f3a4b1c2d3e4f"
}
}
Top-level HTTP status:
| Status | Meaning |
|---|---|
200 | Success. |
400 | Malformed request body or invalid input schema. |
401 | Missing / invalid API key. |
403 | Authenticated but lacking permission (e.g. capability_violation). |
404 | Unknown pack / run / model name. |
410 | Pack version is deprecated. |
429 | Rate-limited (per-key or per-pack). |
500 | Internal Moon error. |
502 | Upstream LLM error (the body’s error.upstream is the verbatim upstream payload). |
Rate limits
Per API key: 60 requests/minute by default; bumpable by an admin. Per pack: configurable via the SoyaPack’s sla: block (v1).
Webhooks
Configure via moon.config.yaml (see Self-host a Moon on a VPS). Payload is a JSON array of Scope events, signed by the Moon with HMAC-SHA256 over the body:
X-Soya-Signature: sha256=…
Verify with the per-tenant webhook secret.
OpenAPI
The full schema is served at /v1/openapi.json (auth-gated). Build SDK code from it the standard way:
curl -H "Authorization: Bearer $KEY" https://moon.example.com/v1/openapi.json > soya.json
openapi-generator-cli generate -i soya.json -g typescript-fetch -o ./client