Coqui HTTP API
The Coqui HTTP API provides programmatic access to Coqui's AI agent capabilities. It enables headless operation, remote session management, and real-time streaming of agent responses via Server-Sent Events (SSE).
The API is built on ReactPHP and runs as a long-lived PHP process. It shares the same core engine as the terminal REPL but without any terminal I/O dependency.
Starting the Server
# Default: localhost:3300
php bin/coqui api
# Custom host and port
php bin/coqui api --host 0.0.0.0 --port 3000
# With a specific config file
php bin/coqui api --config /path/to/openclaw.json
# With CORS origins restricted
php bin/coqui api --cors-origin "http://localhost:3000,https://app.example.com"
# Docker
make docker-api # port 3300
make docker-api PORT=3000 # custom port
CLI Options
| Option | Short | Default | Description |
|---|---|---|---|
--port |
3300 |
Port to listen on | |
--host |
127.0.0.1 |
Host to bind to | |
--config |
-c |
./openclaw.json |
Path to openclaw.json config |
--workdir |
-w |
Current directory | Working directory (project root) |
--unsafe |
false |
Disable script sanitization (dangerous) | |
--no-auth |
false |
Run without API key authentication (forces 127.0.0.1 binding) | |
--cors-origin |
* |
Allowed CORS origins (comma-separated) |
Authentication
When an API key is configured, all requests (except GET /api/health and OPTIONS) must include the key in the Authorization header.
Authorization: Bearer <your-api-key>
Configuring the API Key
The server resolves the API key from these sources (first match wins):
api.keyfield inopenclaw.jsonCOQUI_API_KEYenvironment variableCOQUI_API_KEYin the workspace.envfile
If no key is found, the server refuses to start. This is a security-by-default policy.
To run without authentication during local development, use:
php bin/coqui api --no-auth
The --no-auth flag forces binding to 127.0.0.1 regardless of the --host option.
You can generate an API key automatically by running coqui setup.
Error Responses
Unauthenticated requests receive:
{
"error": "Missing Authorization header",
"code": "unauthorized"
}
Base URL
All endpoints are prefixed with /api. The default base URL is:
http://127.0.0.1:3300
Content Type
All request bodies must be JSON with Content-Type: application/json.
All responses return Content-Type: application/json unless noted otherwise.
Error Format
All error responses use a consistent envelope:
{
"error": "Human-readable error description",
"code": "machine_readable_code"
}
The code field is a stable machine-readable string that clients can branch on without parsing the error message. Some errors include an additional details field with structured context.
Error codes:
| Code | HTTP Status | Description |
|---|---|---|
not_found |
404 | Resource not found |
session_not_found |
404 | Session does not exist |
turn_not_found |
404 | Turn does not exist |
role_not_found |
404 | Role does not exist |
credential_not_found |
404 | Credential does not exist |
validation_error |
400 | Invalid input data |
missing_field |
400 | Required field not provided |
invalid_format |
400 | Field value has wrong format |
conflict |
409 | Resource already exists |
agent_busy |
409 | Session already has an active agent run |
role_builtin |
409 | Cannot modify a built-in role |
role_reserved |
409 | Cannot create a role with a reserved name |
unauthorized |
401 | Missing or invalid API key |
forbidden |
403 | Access denied |
rate_limited |
429 | Too many requests |
payload_too_large |
413 | Request body exceeds size limit |
unsupported_media_type |
415 | Content-Type must be application/json |
internal_error |
500 | Internal server error |
HTTP status codes follow standard conventions.
Endpoints
Health
GET /api/health
Liveness check. Does not require authentication.
Response 200
{
"status": "ok",
"version": "dev",
"uptime_seconds": 3421,
"active_sessions": 1
}
Sessions
A session is a persistent conversation context. Messages and turns are scoped to a session.
GET /api/sessions
List sessions, ordered by most recently updated.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 |
Max sessions to return (capped at 200) |
Response 200
{
"sessions": [
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"model_role": "orchestrator",
"model": "openai/gpt-5",
"created_at": "2026-02-16T14:30:00+00:00",
"updated_at": "2026-02-16T15:45:12+00:00",
"token_count": 12450
}
],
"count": 1
}
POST /api/sessions
Create a new session.
Request Body
{
"model_role": "orchestrator"
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
model_role |
string | No | "orchestrator" |
Role to resolve the model from config. Must be a known role. |
Response 201
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"model_role": "orchestrator",
"model": "openai/gpt-5"
}
Response 400 — Unknown role:
{
"error": "Unknown model_role 'nonexistent'. Available roles: orchestrator, coder",
"code": "validation_error"
}
GET /api/sessions/{id}
Get session details.
Response 200
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"model_role": "orchestrator",
"model": "openai/gpt-5",
"created_at": "2026-02-16T14:30:00+00:00",
"updated_at": "2026-02-16T15:45:12+00:00",
"token_count": 12450
}
Response 404
{
"error": "Session not found",
"code": "session_not_found"
}
DELETE /api/sessions/{id}
Delete a session and all its associated data.
Response 200
{
"deleted": true,
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
}
Messages
Messages are the conversation records within a session. Each message has a role (user, assistant, or tool).
GET /api/sessions/{id}/messages
List all messages in a session, ordered chronologically.
Response 200
{
"session_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"messages": [
{
"id": "m1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"role": "user",
"content": "List the files in the current directory",
"tool_calls": null,
"tool_call_id": null,
"created_at": "2026-02-16T14:30:05+00:00"
},
{
"id": "m2a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"role": "assistant",
"content": "I'll list the files for you.",
"tool_calls": "[{\"id\":\"call_abc\",\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}]",
"tool_call_id": null,
"created_at": "2026-02-16T14:30:07+00:00"
},
{
"id": "m3a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"role": "tool",
"content": "README.md\nsrc/\ntests/\ncomposer.json",
"tool_calls": null,
"tool_call_id": "call_abc",
"created_at": "2026-02-16T14:30:08+00:00"
},
{
"id": "m4a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"role": "assistant",
"content": "Here are the files in the current directory:\n\n- README.md\n- src/\n- tests/\n- composer.json",
"tool_calls": null,
"tool_call_id": null,
"created_at": "2026-02-16T14:30:10+00:00"
}
],
"count": 4
}
POST /api/sessions/{id}/messages
Send a prompt to the agent. This is the core endpoint for interacting with Coqui.
By default, the response is a Server-Sent Event (SSE) stream that delivers real-time updates as the agent works (tool calls, results, content, etc.). Append ?stream=false for a blocking JSON response.
Request Body
{
"prompt": "What files are in the src directory?"
}
| Field | Type | Required | Description |
|---|---|---|---|
prompt |
string | Yes | The user prompt to send to the agent |
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
stream |
string | "true" |
Set to "false" for a blocking JSON response |
Response 200 (SSE Stream)
The response uses Content-Type: text/event-stream. Each event follows the SSE format:
event: <event_type>
data: <json_payload>
Events are separated by a blank line. The stream ends when the complete event is sent and the connection closes.
SSE Event Types
| Event | Description | Data Shape |
|---|---|---|
agent_start |
Agent turn has begun | {} |
iteration |
Agent loop iteration | {"number": 1} |
tool_call |
Agent is calling a tool | {"id": "call_abc", "tool": "list_dir", "arguments": {"path": "."}} |
tool_result |
Tool execution completed | {"content": "...", "success": true} |
child_start |
Child agent spawned | {"role": "coder", "depth": 0} |
child_end |
Child agent finished | {"depth": 0} |
done |
Agent turn content complete | {"content": "Here are the files..."} |
error |
An error occurred | {"message": "Error description"} |
complete |
Final event with full turn result | See below |
complete Event Data
The complete event carries the full turn result:
{
"content": "Here are the files in the src directory...",
"iterations": 2,
"prompt_tokens": 1250,
"completion_tokens": 340,
"total_tokens": 1590,
"duration_ms": 4521,
"tools_used": ["list_dir"],
"child_agent_count": 0,
"restart_requested": false,
"error": null
}
Example SSE Stream
event: agent_start
data: {}
event: iteration
data: {"number":1}
event: tool_call
data: {"id":"call_abc","tool":"list_dir","arguments":{"path":"src"}}
event: tool_result
data: {"content":"Agent/\nApi/\nCommand/\nConfig/\n","success":true}
event: iteration
data: {"number":2}
event: done
data: {"content":"Here are the directories inside `src/`:\n\n- Agent/\n- Api/\n- Command/\n- Config/"}
event: complete
data: {"content":"Here are the directories inside `src/`:\n\n- Agent/\n- Api/\n- Command/\n- Config/","iterations":2,"prompt_tokens":1250,"completion_tokens":340,"total_tokens":1590,"duration_ms":4521,"tools_used":["list_dir"],"child_agent_count":0,"restart_requested":false,"error":null}
Response 200 (Blocking JSON — ?stream=false)
When streaming is disabled, the server blocks until the agent completes and returns the full result:
{
"content": "Here are the files in the src directory...",
"iterations": 2,
"prompt_tokens": 1250,
"completion_tokens": 340,
"total_tokens": 1590,
"duration_ms": 4521,
"tools_used": ["list_dir"],
"child_agent_count": 0,
"restart_requested": false,
"error": null
}
Prompt Size Limit
The prompt field is limited to 100 KB (102,400 bytes). Prompts exceeding this limit return a 400 error with code validation_error.
Error Responses
| Status | Code | Condition |
|---|---|---|
400 |
missing_field |
Missing or empty prompt field |
400 |
validation_error |
Prompt exceeds 100 KB size limit |
404 |
session_not_found |
Session does not exist |
409 |
agent_busy |
Session already has an active agent run |
Turns
A turn represents a single request-response cycle within a session. Each turn contains the user prompt, agent response, token usage, timing, and tool usage metadata.
GET /api/sessions/{id}/turns
List turns for a session, ordered by turn number.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 |
Max turns to return (capped at 200) |
Response 200
{
"session_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"turns": [
{
"id": "t1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"session_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"turn_number": 1,
"user_prompt": "List the files in the current directory",
"response_text": "Here are the files...",
"model": "openai/gpt-5",
"prompt_tokens": 1250,
"completion_tokens": 340,
"total_tokens": 1590,
"iterations": 2,
"duration_ms": 4521,
"tools_used": "[\"list_dir\"]",
"child_agent_count": 0,
"created_at": "2026-02-16T14:30:05+00:00",
"completed_at": "2026-02-16T14:30:10+00:00"
}
],
"count": 1
}
GET /api/sessions/{id}/turns/{turnId}
Get a single turn with its associated messages.
Response 200
{
"id": "t1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6",
"session_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"turn_number": 1,
"user_prompt": "List the files in the current directory",
"response_text": "Here are the files...",
"model": "openai/gpt-5",
"prompt_tokens": 1250,
"completion_tokens": 340,
"total_tokens": 1590,
"iterations": 2,
"duration_ms": 4521,
"tools_used": "[\"list_dir\"]",
"child_agent_count": 0,
"created_at": "2026-02-16T14:30:05+00:00",
"completed_at": "2026-02-16T14:30:10+00:00",
"messages": [
{
"id": "m1...",
"role": "user",
"content": "List the files in the current directory",
"tool_calls": null,
"tool_call_id": null,
"created_at": "2026-02-16T14:30:05+00:00"
}
]
}
Configuration
GET /api/config
Returns the full Coqui configuration. API keys in provider configs are masked as "***".
Response 200
{
"agents": {
"defaults": {
"workspace": ".workspace",
"model": {
"primary": "openai/gpt-5"
},
"roles": {
"orchestrator": "openai/gpt-5",
"coder": "openai/gpt-5",
"reviewer": "openai/gpt-5"
}
}
},
"models": {
"mode": "merge",
"providers": {
"openai": {
"baseUrl": "https://api.openai.com/v1",
"api": "openai-completions",
"apiKey": "***",
"models": ["..."]
}
}
}
}
GET /api/config/roles
Returns all roles with full metadata. The response merges three layers:
- System roles (e.g.
orchestrator) — always present,is_system: true,editable: false. - Config roles — defined in
openclaw.jsonunderagents.defaults.roles. - Custom roles — user-created role files in
roles/.
Response 200
{
"roles": [
{
"name": "orchestrator",
"model": "openai/gpt-5",
"display_name": "Orchestrator",
"description": "Primary system role with full tool access...",
"access_level": "full",
"is_builtin": true,
"is_system": true,
"editable": false
},
{
"name": "coder",
"model": "openai/gpt-5",
"display_name": "Coder",
"description": "Writes and refactors code",
"access_level": "full",
"is_builtin": false,
"is_system": false,
"editable": true
}
],
"count": 2
}
GET /api/config/roles/{name}
Get a single role with full details. System roles return metadata without instructions. Custom roles include the full instruction text.
Response 200 (custom role):
{
"name": "coder",
"display_name": "Coder",
"description": "Writes and refactors code",
"version": 1,
"access_level": "full",
"is_builtin": false,
"is_system": false,
"editable": true,
"model": "openai/gpt-5",
"instructions": "You are a coding specialist..."
}
Response 404
{
"error": "Role 'nonexistent' not found",
"code": "role_not_found"
}
POST /api/config/roles
Create a new custom role.
Request Body
{
"name": "debugger",
"display_name": "Debugger",
"description": "Specializes in finding and fixing bugs",
"access_level": "full",
"model": "anthropic/claude-sonnet-4-20250514",
"instructions": "You are a debugging specialist..."
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique role name (cannot be a reserved name) |
instructions |
string | Yes | System prompt for the role |
display_name |
string | No | Human-readable name (defaults to capitalized name) |
description |
string | No | Brief description |
access_level |
string | No | full, readonly, or minimal (default: readonly) |
model |
string | No | Model override for this role |
Response 201
Returns the created role properties with instructions.
Response 409 — reserved name:
{
"error": "Role name 'orchestrator' is reserved and cannot be created",
"code": "role_reserved"
}
Response 409 — already exists:
{
"error": "Role 'coder' already exists",
"code": "conflict"
}
PATCH /api/config/roles/{name}
Update an existing custom role. All fields are optional — only provided fields are changed.
Request Body
{
"description": "Updated description",
"instructions": "Updated system prompt..."
}
System roles cannot be modified:
{
"error": "System role 'orchestrator' cannot be modified",
"code": "role_builtin"
}
DELETE /api/config/roles/{name}
Delete a custom role.
Response 200
{
"deleted": true,
"name": "debugger"
}
System and built-in roles cannot be deleted:
{
"error": "System role 'orchestrator' cannot be deleted",
"code": "role_builtin"
}
GET /api/config/models
Lists all available models from all configured providers.
Response 200
{
"models": [
{
"provider": "openai",
"id": "openai/gpt-5",
"name": "gpt-5",
"reasoning": false,
"input": ["text"]
},
{
"provider": "anthropic",
"id": "anthropic/claude-sonnet-4-20250514",
"name": "claude-sonnet-4-20250514",
"reasoning": true,
"input": ["text"]
}
],
"count": 2,
"primary": "openai/gpt-5"
}
Credentials
Credential values are never returned by the API. Only key names and existence are exposed.
GET /api/credentials
List all stored credential keys.
Response 200
{
"credentials": [
{
"key": "OPENAI_API_KEY",
"is_set": true
},
{
"key": "BRAVE_API_KEY",
"is_set": true
}
],
"count": 2
}
POST /api/credentials
Set or update a credential. The value is stored in the workspace .env file and made available immediately via putenv().
Request Body
{
"key": "BRAVE_API_KEY",
"value": "BSA1234567890abcdef"
}
| Field | Type | Required | Validation |
|---|---|---|---|
key |
string | Yes | Must be UPPER_SNAKE_CASE (e.g. MY_API_KEY) |
value |
string | Yes | The credential value |
Response 201
{
"key": "BRAVE_API_KEY",
"set": true
}
Response 400
{
"error": "Invalid key format. Use UPPER_SNAKE_CASE (e.g. MY_API_KEY)",
"code": "invalid_format"
}
DELETE /api/credentials/{key}
Delete a credential.
Response 200
{
"key": "BRAVE_API_KEY",
"deleted": true
}
Response 404
{
"error": "Credential not found",
"code": "credential_not_found"
}
Middleware
Rate Limiting
The API enforces per-IP rate limiting using an in-memory token bucket. When the limit is exceeded, requests receive 429 Too Many Requests.
Default: 30 requests per 60 seconds per IP.
Configure via openclaw.json:
{
"api": {
"rateLimit": {
"maxRequests": 30,
"windowSeconds": 60
}
}
}
Response headers (on all requests):
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed per window |
X-RateLimit-Remaining |
Remaining requests in current window |
Rate limited response (429):
{
"error": "Rate limit exceeded. Try again later.",
"code": "rate_limited"
}
The response includes a Retry-After header with the number of seconds to wait.
Exempt endpoints: GET /api/health, OPTIONS (preflight).
Request Size Limit
Request bodies are limited to 1 MB (1,048,576 bytes). Requests exceeding this limit receive 413 Payload Too Large.
{
"error": "Request body too large. Maximum size: 1048576 bytes",
"code": "payload_too_large"
}
Only POST, PUT, and PATCH requests are checked.
Content-Type Enforcement
All POST, PUT, and PATCH requests must include a Content-Type header containing application/json. Missing or incorrect content types receive 415 Unsupported Media Type.
{
"error": "Content-Type must be application/json",
"code": "unsupported_media_type"
}
CORS
The server includes CORS headers on all responses. By default, all origins are allowed (*). Restrict origins with the --cors-origin flag:
php bin/coqui api --cors-origin "http://localhost:3000,https://myapp.com"
Preflight OPTIONS requests are handled automatically with a 204 response.
Safety
The API server enforces the same layered safety model as the terminal REPL:
- Catastrophic Blacklist — hardcoded patterns that always block destructive commands (
rm -rf /,shutdown, fork bombs, etc.). Cannot be bypassed. - Script Sanitizer — static analysis of generated PHP code. Blocks
eval,exec,system, etc. Disabled with--unsafe. - Auto-Approval — in API mode, tool executions are auto-approved (no interactive prompt). The catastrophic blacklist still applies.
Concurrency
Each prompt submission runs inside a PHP Fiber. The ReactPHP event loop remains responsive while agent turns execute. Only one agent run per session is allowed at a time — concurrent requests to the same session return 409 Conflict.
Quick Reference
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/health |
No | Server liveness check |
GET |
/api/sessions |
Yes | List sessions |
POST |
/api/sessions |
Yes | Create session |
GET |
/api/sessions/{id} |
Yes | Get session |
DELETE |
/api/sessions/{id} |
Yes | Delete session |
GET |
/api/sessions/{id}/messages |
Yes | List messages |
POST |
/api/sessions/{id}/messages |
Yes | Send prompt (SSE stream) |
GET |
/api/sessions/{id}/turns |
Yes | List turns |
GET |
/api/sessions/{id}/turns/{turnId} |
Yes | Get turn with messages |
GET |
/api/config |
Yes | Get config (sanitized) |
GET |
/api/config/roles |
Yes | List all roles |
GET |
/api/config/roles/{name} |
Yes | Get role detail |
POST |
/api/config/roles |
Yes | Create custom role |
PATCH |
/api/config/roles/{name} |
Yes | Update custom role |
DELETE |
/api/config/roles/{name} |
Yes | Delete custom role |
GET |
/api/config/models |
Yes | List available models |
GET |
/api/credentials |
Yes | List credential keys |
POST |
/api/credentials |
Yes | Set a credential |
DELETE |
/api/credentials/{key} |
Yes | Delete a credential |