POC agent engine for Appwrite Cloud /v1/agent, built on LangGraph.
Stateless by design. Cloud (or any proxy) owns conversation persistence, Realtime, and auth. The UI/client owns MCP OAuth and credentials. This service is the agent runtime only — every turn receives the context it needs over the API.
- One-shot router + subagents (
platform,researcher,planner) via LangGraphcreate_react_agent— each turn routes once (with an optional planner fallback if the primary agent stalls) - Platform agent with official agent-skills vendored under
.agents/skills/ - Safe tools by default — no host shell for the model
- Tools:
calculator,current_time,web_search,browser_fetch,http_get,appwrite_skill,sandbox_execstub,console(Console UI protocol),clarify(structured follow-ups),memory(lasting preferences), plus MCP tools from credentials on the turn - FastAPI + shadcn chat UI
- Console protocol (
appwrite.console/v1) — agent → Console metadata for theme, navigation, toasts, resource cards, lists, and usage charts - Clarify protocol (
appwrite.clarify/v1) — agent → Console structured prompts (choice / confirm / text) when details are missing or deletes need confirmation
Each POST /api/turn is one independent run. The engine builds tools for that turn (built-ins + MCP from the request), routes once with structured output, then streams a single LangGraph ReAct subagent.
flowchart TD
req["POST /api/turn<br/>message · history · attachments · mcp_connections · llm"]
prep["Build tools<br/>built-ins + MCP write-guard wrappers"]
route["Supervisor router<br/>structured Route: next + reason"]
finish["FINISH<br/>emit final_answer"]
plat["platform ReAct agent"]
res["researcher ReAct agent"]
plan["planner ReAct agent"]
fallback{"Primary stalled?<br/>researcher / platform only"}
done["SSE done · optional conversation_title"]
req --> prep --> route
route -->|FINISH| finish --> done
route -->|platform| plat
route -->|researcher| res
route -->|planner| plan
plat --> fallback
res --> fallback
plan --> done
fallback -->|yes| plan
fallback -->|no| done
| Role | Responsibility | When it runs | Tools |
|---|---|---|---|
| Supervisor | Pick one subagent (or answer directly) | Every turn | None — structured Route (next, reason, optional final_answer) |
| platform | Appwrite product specialist — SDKs, CLI, Auth, TablesDB, Storage, Functions, Sites, Messaging, permissions, Cloud vs self-hosted, and live project ops via MCP | Product/SDK/CLI questions and in-project mutations when MCP is connected | appwrite_skill + shared tools + MCP + console + clarify |
| researcher | Open-web fact finding | News, lookups, calc, fetch public pages | Shared tools + MCP |
| planner | Plans, structured guidance, Console UI side-effects, sandbox proposals; also the fallback if platform/researcher stall | Non-Appwrite planning, Console UI-only asks, or recovery after a stalled primary | Shared tools + MCP + console + clarify |
Shared tools:calculator, current_time, web_search, browser_fetch, http_get (raw HTTPS text/JSON), sandbox_exec (stub), console (UI protocol), clarify (structured follow-ups), memory (set/forget lasting preferences). MCP tools from mcp_connections are attached to every subagent; create mutations are write-guarded (dedupe / already_exists recovery).
Console protocol. After MCP create/update/delete (or when the user asks to change theme / navigate / open a dialog), agents call the console tool with a JSON action list. For list/query answers (databases, users, …) they emit resource_list instead of markdown tables. For usage metrics they emit chart (e.g. metric network.requests with an interval). The tool returns an appwrite.console/v1 envelope on tool_end; the Console parses it into cards, lists, and shell side-effects. See docs/console-protocol.md.
Clarify protocol. When a required detail is missing or a destructive action needs confirmation, agents call the clarify tool with choice / confirm / text prompts instead of guessing IDs or permissions. The tool returns an appwrite.clarify/v1 envelope; the Console renders the form and the next user turn carries the answers. See docs/clarify-protocol.md.
Memory protocol. When the user asks to remember or forget lasting preferences/instructions, agents call the memory tool (type=set / type=forget). The tool returns an appwrite.memory/v1 envelope; Cloud persists successful calls into agentMemories and injects active memories on later turns.
Turn shape: route once → stream one subagent → optional planner fallback → done. History is trimmed to the last 12 turns server-side. No durable graph checkpoint — Cloud/proxy owns conversation state.
cp .env.example .env
# set AGENT_API_KEY and LLM_API_KEY# Stamp the image so /health and startup logs show which build is runningexport AGENT_BUILD_ID="$(git rev-parse --short HEAD)"export AGENT_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker compose up --build -d
curl -s http://127.0.0.1:8000/health
# → {"status":"ok","build_id":"abc1234","build_time":"2026-08-01T17:56:00Z"}
curl -sN -H "X-Session-API-Key: $AGENT_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"message":"What is 17*19?","history":[]}' \
http://127.0.0.1:8000/api/turn- API docs: http://127.0.0.1:8000/docs
- UI: http://127.0.0.1:3001
cd /path/to/eldadfux/openhands
docker build \
--build-arg AGENT_BUILD_ID="$(git rev-parse --short HEAD)" \
--build-arg AGENT_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t ghcr.io/eldadfux/openhands:dev \
.# Then recreate the cloud compose service that pulls that tag:# cd cloud && docker compose --profile agent up -d --force-recreate appwrite-agent# curl -s http://127.0.0.1:8000/health && docker logs appwrite-agent 2>&1 | head -20| Method | Path | Notes |
|---|---|---|
| GET | /health | Liveness (+ build id/time) |
| GET | /ready | Env LLM_API_KEY present? |
| GET | /api/meta | Auth — runtime inspection (secrets masked) |
| POST | /api/title | Auth — short topic title for a conversation opener |
| POST | /api/turn | Auth — one turn (SSE) |
POST /api/turn requires either env LLM_API_KEY or a per-turn llm.api_key. /ready only checks the env default.
{
"message": "What is 17*19?",
"history": [
{ "role": "user", "content": "Hi" },
{ "role": "assistant", "content": "Hello!" }
],
"attachments": [
{ "name": "notes.txt", "mime": "text/plain", "content_base64": "..." }
],
"mcp_connections": [
{
"id": "appwrite",
"name": "Appwrite",
"url": "https://mcp.appwrite.io/",
"tokens": { "access_token": "...", "refresh_token": "..." },
"client_info": { "client_id": "..." }
}
],
"llm": {
"api_key": "...",
"model": "openai/gpt-5.6",
"base_url": "https://api.openai.com/v1",
"temperature": 0.2
}
}| Field | Required | Notes |
|---|---|---|
message | one of message / attachments | User text for this turn |
history | no | Prior {role, content} pairs (last 12 kept server-side) |
attachments | no | Inline files via content_base64 |
mcp_connections | no | Full MCP URL + tokens + client_info for this turn |
llm | no | Per-turn credential/model override (see below) |
llm override. Optional object merged over env LLM_* for this turn only. Omitted fields keep the env defaults. Cloud uses this when a conversation selects a user-owned model (agentModels); omit it to use the shared Appwrite default. The key is never logged. GPT-5 reasoning models and the o-series omit custom temperature and run on the Responses API (use_responses_api) so function tools work; chat models like gpt-4o keep Chat Completions + temperature.
{ "message": "How do I create a database?", "assistant_message": "..." }Returns { "title": "..." }. Uses the env LLM only (no per-turn llm override).
SSE events from /api/turn include status, route, subagent_start / subagent_end, answer_start, tool_start / tool_end, token, mcp_credentials (refreshed tokens for the client to store), conversation_title (first turn only), done, error, and a final complete.
The engine does not run OAuth. The UI (or production proxy) does:
- Discover protected-resource + authorization-server metadata
- Dynamic client registration (public client + PKCE)
- Browser authorize → callback at
{origin}/oauth/mcp/callback - Store
tokens+client_infoin localStorage - Send them on every
/api/turnasmcp_connections
Production Appwrite should own steps 1–4 and replay credentials on each turn the same way.
| Variable | Required | Purpose |
|---|---|---|
AGENT_API_KEY | yes (prod) | Clients send X-Session-API-Key |
LLM_API_KEY | yes* | Default model provider API key (* or supply llm.api_key per turn) |
LLM_MODEL | no | Default openai/gpt-5.6 (overridable via llm.model) |
LLM_BASE_URL | no | Optional OpenAI-compatible base URL (overridable via llm.base_url) |
WEB_SEARCH_ENABLED | no | Headless browser web search (default true) |
ATTACHMENTS_MAX_BYTES | no | Max inline attachment size (default 10MB) |
ATTACHMENTS_MAX_PER_MESSAGE | no | Max attachments per turn (default 8) |
STREAM_TOOL_INPUT_CHARS | no | Cap tool-input preview on SSE (default 100000) |
STREAM_TOOL_OUTPUT_CHARS | no | Cap tool-output preview on SSE (default 500000) |
- Do not expose this container on a public Gateway/HTTPRoute.
- The model cannot run host shell commands.
- Unauthenticated mode (empty API key) is for local smoke tests only.
./scripts/update-appwrite-skills.sh
docker compose up --build -d agentpython -m venv .venv &&source .venv/bin/activate
pip install -r requirements.txt
playwright install chromium
export LLM_API_KEY=... AGENT_API_KEY=...
uvicorn app.main:app --reload --port 8000