
Makers Agents
- 20 installs
- 2k repo stars
- Updated July 16, 2026
- tencentedgeone/edgeone-pages-skills
Use for agent-tooling work
About
Makers Agents skill provides developer tools and automation. Complexity: intermediate.
- Makers Agents
Makers Agents by the numbers
- 20 all-time installs (skills.sh)
- Ranked #10,442 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tencentedgeone/edgeone-pages-skills --skill makers-agentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 2k |
| Last updated | July 16, 2026 |
| Repository | tencentedgeone/edgeone-pages-skills ↗ |
What it does
Use for agent-tooling work
Files
EdgeOne Makers Agent Development Guide
Build production-grade AI agent endpoints on EdgeOne Makers — five framework routes, platform-injected runtime, file-based routing.
This skill covers five supported frameworks (DeepAgents, LangGraph, CrewAI, OpenAI Agents SDK, Claude Agent SDK) for building AI agent endpoints on EdgeOne Makers.
When to use this skill
- Creating a new AI agent endpoint on EdgeOne Makers
- Wiring DeepAgents / LangGraph / CrewAI / OpenAI Agents SDK / Claude Agent SDK into a Makers project
- Reviewing an existing agent template against platform red lines
- Implementing SSE streaming with abort support
- Persisting conversation state via
context.store(LangGraph checkpointer / OpenAI session / Claude session) - Calling sandbox or platform tools via
context.sandbox/context.tools - Splitting AI inference (
agents/) from data CRUD (cloud-functions/)
Do NOT use for:
- Plain Edge Functions / Cloud Functions / Middleware → use
makers-cloud-functions/makers-edge-functions/makers-middleware - Deployment workflows → use
makers-deploy - Generic AI framework development outside an EdgeOne Makers project
- Other platforms (Cloudflare Workers AI, Vercel AI SDK, AWS Bedrock)
How to use this skill (for a coding agent)
1. Skim the Mental Model below — Makers ≠ generic API routes 2. Walk the Decision Tree to pick one of the five framework routes 3. Read the matching references/*-route.md for a copy-paste skeleton 4. Self-check against the Twelve Red Lines 5. Run through references/review-checklist.md before considering the work done
⛔ Critical Rules (never skip)
1. File-based routing is automatic. agents/<name>/index.ts or agents/<name>.ts becomes POST /<name>. Never hand-edit .edgeone/agent-node/config.json. 2. Entry signature is fixed. TS: export async function onRequest(context: any). Python: async def handler(ctx):. Method-specific variants (onRequestPost, onRequestGet, etc.) also work for TS. 3. Read env via `context.env`, never `process.env` / `os.environ`. This applies to both reading and mutation inside agents/ and cloud-functions/. Frontend code (app/, src/) is unaffected. 4. Headers are plain objects, not the Web `Headers` API. Use context.request.headers['x-custom-header'], never .get('x'). 5. Conversation ID contract. AI endpoints (/chat, /outline, etc.) MUST receive the makers-conversation-id HTTP header from the frontend. The /stop endpoint takes a conversation_id in the request body to identify which running conversation to cancel. 6. Do not hardcode model name / base URL / API key. Read AI_GATEWAY_API_KEY + AI_GATEWAY_BASE_URL (+ optional AI_GATEWAY_MODEL) from context.env. If your template uses context.tools.web_search, also configure WSA_API_KEY (Tencent Cloud WSAPI). 7. SSE protocol is a recommended convention (not enforced by the runtime). The runtime only forwards raw chunks — it does not parse or validate SSE content. The recommended event types are: ai_response / tool_call / tool_result / usage / suggest_actions / file_output / ping / error_message. Stream ends with data: [DONE]\n\n. All frameworks should follow this for frontend consistency. 8. Heartbeat + buffering control are mandatory. Send a ping event every 5 s. Response headers must include X-Accel-Buffering: no, Cache-Control: no-cache, Connection: keep-alive. 9. Always honor `context.request.signal`. Check signal?.aborted (TS) or signal.is_set() (Python) inside loops; exit gracefully on abort, do not throw. 10. Cap your loops. Manual bind-tools loops use a hard turn limit (e.g. for (let i = 0; i < 4; i++)); SDK routes set maxTurns. No unbounded "until model says stop" loops. 11. Errors must not crash the stream. Wrap every model / tool call in try/catch. Swallow AbortError silently. Emit other errors as error_message events without ending the stream prematurely. 12. Pick the right `store` entry point — they are NOT shape-equivalent.
context.store(agent endpoints,agents/<name>/): fullAgentMemory, includes all adapters (openaiSession,claudeSessionStore,langgraphCheckpointer,langgraphStore).context.agent.store(cloud-function endpoints,cloud-functions/<name>/): runtime stripslanggraphCheckpointerandlanggraphStore. Only generic message API +openaiSession+claudeSessionStoreare available.- Consequence: any endpoint that needs
langgraphStore.get/putMUST live underagents/. Putting it incloud-functions/will throwkv.get is not a functionat runtime. - Never write
store?.langgraphStore ?? storeas a fake fallback — in cloud-function context this falls back to the store itself, which has no.get, and crashes.
13. Use injected `context.sandbox` / `context.tools`. Do not hand-write /v1/sandbox/* calls or parse tokens. context.tools shape is determined by edgeone.json's agents.framework (claude-agent-sdk / openai-agents-sdk / langgraph / crewai / deepagents — there is no `basic`). Use context.tools.all(), .get(name), .files(), .browser(). Sandbox: sandbox.runCode(...) is top-level (not code_interpreter.runCode); screenshot({ fullPage: true }) takes an object, not a boolean; timeout is in seconds.
Note: red line numbering jumps from 12 to 13 deliberately — twelve was the original count; #12 absorbs the store-shape correction with sub-bullets, #13 was added for sandbox/tools to match the breadth of the other rules.
---
Mental Model
EdgeOne Makers Agent is not a generic API route pattern (not Vercel AI SDK's route.ts, not Express). It has its own runtime conventions.
| Dimension | EdgeOne Makers convention | ⚠️ Common mistake |
|---|---|---|
| Backend entry | agents/<name>/index.ts or agents/<name>.ts (Python: .py) | ❌ NOT app/api/<name>/route.ts |
| Function signature | export async function onRequest(context) (Python: async def handler(context)) | ❌ NOT export async function POST(req) |
| Request body | context.request.body (already parsed) | ❌ NOT await req.json() |
| Request headers | context.request.headers['x-foo'] (plain object) | ❌ NOT headers.get('x-foo') (silently returns undefined) |
| Environment | context.env.AI_GATEWAY_API_KEY (runtime-injected) | ❌ NOT process.env.X / os.environ (banned in agents/ and cloud-functions/) |
| Model access | context.env.AI_GATEWAY_* → Makers AI Gateway | ❌ NOT direct OpenAI / Anthropic |
| Platform capabilities | context.tools / context.sandbox / context.store injected by runtime | ❌ NOT importing the SDK yourself |
| Route registration | Auto-scanned at build time → .edgeone/agent-node/config.json | ❌ Don't write that file by hand |
The core idea: you write a thin handler that runs inside the EdgeOne Agent Node Runtime (or Python Runtime). The platform injects the model gateway, sandbox, tools, and session store via context. Your code stays thin and leans on the runtime.---
Standard Project Layout
<template-name>-edgeone/
├── agents/ # ⭐ Agent backend (core)
│ ├── _shared.ts # Shared: logger + SSE helper
│ ├── _model.ts # Shared: model name + Gateway env mapping
│ ├── <action>.ts # Simple agent: single file → POST /<action>
│ └── <action>/ # Complex agent: directory form
│ ├── index.ts # onRequest entry → POST /<action>
│ ├── _skills.ts # System prompt builder (optional)
│ ├── _tools.ts # Custom / MCP tool definitions (optional)
│ └── _templates.ts # Output templates / default data (optional)
├── app/ or src/ # Frontend (any framework: Next.js, Vite, plain HTML, etc.)
│ ├── layout.tsx
│ ├── page.tsx
│ ├── globals.css
│ ├── components/
│ └── lib/ # Frontend utils (context, hooks, conversation-id)
├── lib/ # Cross-cutting utils (i18n, helpers)
├── cloud-functions/ # ⭐ Data persistence functions (separate from agents)
│ ├── _logger.ts
│ └── <resource>/index.ts # e.g. articles/, preferences/, history/, health/
├── .edgeone/
│ └── project.json # { Name, ProjectId }
├── edgeone.json # Deployment config + agents.framework
├── package.json # TS routes (A/B/C/D)
├── requirements.txt # ⭐ Python route (E) only
└── README.mdLayout principles
- `agents/` = AI inference: model calls, streaming, tool calling. Each file/directory is one SSE endpoint.
- `cloud-functions/` = data CRUD: KV/Blob reads/writes, health checks, history. Returns JSON; not streamed.
- `_`-prefixed files = internal modules: not routed; imported by siblings only.
- `_shared.ts`, `_model.ts`, `_tools.ts` are internal;
index.ts,create.tsare endpoints. - Pick TS or Python per template, do not mix in one project.
---
edgeone.json Configuration
The edgeone.json file is the deployment configuration file for EdgeOne Makers projects. It defines the build command, output directory, and agent-specific settings.
Key Fields
| Field | Type | Description |
|---|---|---|
buildCommand | string | Build command (e.g., npm run build) |
outputDirectory | string | Build output directory (e.g., .next, dist, build) |
framework | string | Frontend framework (e.g., nextjs, vite, react) |
cloudFunctions | object | Cloud functions configuration |
agents | object | Agent-specific settings (important!) |
agents.framework — Console Icon Display
The agents.framework field in edgeone.json tells the EdgeOne Makers console which icon to display for your project. This is required for the console to show the correct framework icon.
Available values:
| Value | Framework | Console Icon |
|---|---|---|
claude-agent-sdk | Claude Agent SDK | Claude |
openai-agents-sdk | OpenAI Agents SDK | OpenAI |
langgraph | LangGraph / DeepAgents | LangGraph |
crewai | CrewAI | CrewAI |
deepagents | DeepAgents | DeepAgents |
⚠️ Important: If agents.framework is not set or set to an unrecognized value, the console will show a generic icon (not the framework-specific icon).
Example edgeone.json
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"cloudFunctions": {
"nodejs": {
"includeFiles": []
}
},
"agents": {
"framework": "claude-agent-sdk"
}
}For pure backend projects (no frontend), set"buildCommand": ""and"outputDirectory": "".
---
Technology Decision Tree
Pick one of the five framework routes:
Need a sandbox to run code, process uploaded files, or use MCP tools?
├─ Yes → Claude Agent SDK
└─ No ↓
Need multi-agent handoff?
├─ Yes → OpenAI Agents SDK
└─ No ↓
Need fine-grained graph control (nodes, edges, human-in-the-loop)?
├─ Yes → LangGraph
└─ No ↓
Want multi-agent role split (Sequential/Hierarchical)?
├─ Yes → CrewAI (Python only)
└─ No → DeepAgents (simplest, auto context compression)Framework Comparison
| Framework | agents.framework | Runtime | Best For |
|---|---|---|---|
| DeepAgents | deepagents | Node + Python | Simple agent tasks, automatic context compression, sub-agent orchestration |
| LangGraph | langgraph | Node + Python | Fine-grained graph control, human-in-the-loop, persistent thread state |
| Claude Agent SDK | claude-agent-sdk | Node + Python | Sandbox code execution, file processing, MCP tools, session memory |
| OpenAI Agents SDK | openai-agents-sdk | Node + Python | Multi-agent handoff, guardrails, session auto-prepend |
| CrewAI | crewai | Python only | Multi-agent role split (Sequential/Hierarchical), built-in skills/event_bus |
---
Routing
| Topic | Read |
|---|---|
| Node entry (onRequest, context, AbortSignal) | platform/node-entry.md |
| Python entry (handler, ctx, asyncio.Event) | platform/python-entry.md |
| Environment variables + model convention | platform/env-and-model.md |
| SSE streaming protocol | platform/sse-protocol.md |
| conversation-id dual-channel + frontend | platform/conversation-id.md |
| agents/ vs cloud-functions/ separation | platform/cloud-functions.md |
| Store (context.store) | capabilities/store.md |
| Sandbox (context.sandbox) | capabilities/sandbox.md |
| Tools (context.tools) | capabilities/tools.md |
| Claude Agent SDK (Node) | node-frameworks/claude-sdk.md |
| OpenAI Agents SDK (Node) | node-frameworks/openai-agents.md |
| LangGraph (Node) | node-frameworks/langgraph.md |
| DeepAgents (Node) | node-frameworks/deepagents.md |
| Claude Agent SDK (Python) | python-frameworks/claude-sdk.md |
| OpenAI Agents SDK (Python) | python-frameworks/openai-agents.md |
| LangGraph (Python) | python-frameworks/langgraph.md |
| DeepAgents (Python) | python-frameworks/deepagents.md |
| CrewAI (Python only) | python-frameworks/crewai.md |
| Review checklist | review-checklist.md |
---
Environment Setup
Install the EdgeOne CLI
npm install -g edgeoneVerify: edgeone -v.
Set environment variable
Before executing any edgeone CLI command (makers init, makers dev, makers link, makers env pull, etc.), set:
export PAGES_SOURCE=skillsOr prefix each command inline:
PAGES_SOURCE=skills edgeone makers devThis tells the platform that the command was triggered from an AI skill context.
Local development
# 1. Link to remote project (pulls project ID + env vars)
edgeone makers link
# 2. Pull remote environment variables to local .env
edgeone makers env pull
# 3. Start local dev server (agent runtime + frontend)
edgeone makers devedgeone makers dev starts both the agent runtime (Node or Python, auto-detected from agents/ file extensions) and the frontend dev server. All agents/ endpoints become available at http://localhost:<port>/<endpoint>.
Environment variables for deployment
AI Gateway variables (AI_GATEWAY_API_KEY, AI_GATEWAY_BASE_URL) are auto-provisioned by the CLI during deployment — no manual setup needed, as long as .env.example declares them:
# .env.example (commit this to repo)
AI_GATEWAY_API_KEY=
AI_GATEWAY_BASE_URL=The CLI will detect these declarations and automatically fetch + inject the values at deploy time.
User-defined business variables must be set manually before deployment:
# Set a variable on the remote project
edgeone makers env set MY_SECRET_KEY "my-value"
# List current variables
edgeone makers env ls
# Pull remote variables to local .env (for dev)
edgeone makers env pullCommon variables to set for Agent projects:
| Variable | When needed | How to set |
|---|---|---|
AI_GATEWAY_API_KEY | Always | Auto-provisioned by CLI |
AI_GATEWAY_BASE_URL | Always | Auto-provisioned by CLI |
WSA_API_KEY | If using web_search tool | edgeone makers env set WSA_API_KEY <value> |
| Custom business keys | Per project | edgeone makers env set <KEY> <VALUE> |
⚠️ Before deploying an Agent project, ensure all required environment variables are either auto-provisioned (AI_GATEWAY_*) or manually set via edgeone makers env set. Missing variables will cause runtime 500 errors.---
Standard Operating Procedure
Reviewer SOP
1. Run find . -type d -name agents -o -name cloud-functions to confirm directory shape. 2. Open edgeone.json, read agents.framework to identify the route. 3. Walk through references/review-checklist.md from section A onward. 4. When a violation is found, cite the matching Critical Rule + the "remediation table" at the end of the checklist. 5. Top high-frequency issues to attack first (in order of observed frequency): 1. ❌ process.env.X / os.environ inside agents (use context.env); mutation also counts: process.env.X = '...' is a violation too 2. ❌ headers.get('x') (use headers['x']) 3. ❌ Hand-maintained .edgeone/agent-node/config.json (delete it). ⚠️ How to judge: check whether .gitignore includes .edgeone. If yes → the local config.json is a build artifact, not a violation. If no → the whole .edgeone/ is committed, that's the violation. 4. ❌ Writing sandbox.code_interpreter.runCode(...) (it's sandbox.runCode(...), top-level); screenshot(true) should be screenshot({ fullPage: true }) 5. ❌ /stop carrying makers-conversation-id header (use body only) 6. ❌ Frontend fetch to AI endpoints missing makers-conversation-id header 7. ❌ edgeone.json missing agents.framework (default 'claude-agent-sdk' may not match actual framework, breaks context.tools shape)
Developer SOP
1. Pick a framework via the Decision Tree above. 2. Copy the skeleton from the matching framework reference doc. 3. Configure edgeone.json: set agents.framework correctly. 4. Frontend: getOrCreateConversationId + fetch with makers-conversation-id header. 5. Get it running → self-check against the Critical Rules → run through references/review-checklist.md.
Pre-Deploy SOP (⚠️ MUST execute before edgeone makers deploy)
This section is critical. AI agents MUST follow these steps when helping a user deploy. Skipping them will cause runtime 500 errors in production.
1. Scan for environment variables in the project:
- Check
.env,.env.example,.env.localfor all declared variables - Scan source code for
context.env.XXX/ctx.env.get("XXX")references to identify required variables - Common patterns:
SUPABASE_URL,SUPABASE_KEY,DATABASE_URL,WSA_API_KEY, custom API keys, etc.
2. Classify variables:
AI_GATEWAY_API_KEY+AI_GATEWAY_BASE_URL→ auto-provisioned (no action needed if.env.exampledeclares them)- All other variables → must be manually uploaded
3. Upload non-auto-provisioned variables:
# For each variable the project needs:
edgeone makers env set <KEY> "<VALUE>"If the user has not provided the values, ask the user for them before deploying. Do NOT deploy without confirming all required variables are set.
4. Verify (optional but recommended):
edgeone makers env ls5. Deploy:
edgeone makers deployExample interaction when deploying a project with Supabase:
The project uses the following environment variables:
- AI_GATEWAY_API_KEY — auto-provisioned ✓- AI_GATEWAY_BASE_URL — auto-provisioned ✓- SUPABASE_URL — needs manual setup- SUPABASE_ANON_KEY — needs manual setup>
Please provide the values forSUPABASE_URLandSUPABASE_ANON_KEY, and I'll set them before deploying.
---
Sandbox (context.sandbox)
Platform capability: context.sandbox provides sandboxed code execution, file operations, and browser automation.---
0. First Principle: Use Runtime Injection, Don't Roll Your Own
- Inside Pages Agent templates, always use the injected `context.sandbox` / `context.tools` (Python:
ctx.sandbox/ctx.tools). - Do not re-parse tokens, hand-write
/v1/sandbox/*requests, or manually construct a sandbox in business code. context.sandboxis lazily loaded on first access; auth / ProjectId / control-plane env are injected by the runtime or the CLI deploy pipeline. Template.envfiles do not need to carry sandbox tickets, PROJECT_ID, SANDBOX_API_BASE, or API_ENV.- Only use
buildSandboxProxy/build_sandboxfor manual construction when outside the Pages Agent runtime and connecting an SDK directly to the control plane.
⚠️ Distinguish two classes of env:
-AI_GATEWAY_API_KEY/AI_GATEWAY_BASE_URLare business variables for the LLM gateway (required by the agent)
- Sandbox tickets (sandbox.v1.* sealed token) are sandbox-auth variables injected by the deploy pipeline; they are not AI Gateway variables---
⚠️ Must-Read: Sandbox /tmp/ Is Easily Lost Across Requests
This is a platform-level characteristic, not a per-framework limitation:
- Even when the same
conversation_idis sticky-routed to the same sandbox instance, files in the sandbox/tmp/may be cleaned between requests. - Scenario: on the first
/chatrequest the user uploads an image to/tmp/foo.jpgand the AI returns a result; on a later request asking "compress that image",/tmp/foo.jpgmay already be gone.
Correct approach (Route B / Claude SDK template pattern): 1. Cache uploaded files on the backend in a module-level Map<conversationId, Array<{ name, base64 }>> 2. At the start of every request, re-write the cached files back to the sandbox at /tmp/<name> 3. This way the AI can always find the files regardless of whether /tmp/ was cleaned
Anti-patterns:
- Assuming
/tmp/foo.jpgstill exists on the second request → AI hitsFileNotFoundError, and the model may "hallucinate" a fake image as the response - The system prompt must explicitly forbid this: on
FileNotFoundError, the model must stop and never fabricate a file
---
1. Sandbox API (context.sandbox)
| Module | Method | Notes |
|---|---|---|
| commands | run(cmd, {cwd?, env?, user?, timeout?}) → {stdout, stderr, exitCode} | Shell execution; timeout is in seconds; also used to download/generate binary assets |
| files | read / write / list / makeDir / exists / remove | ⚠️ write(path, content) only accepts UTF-8 strings; binary content must be produced inside the sandbox via commands.run('base64 -d ...') |
| browser | goto / screenshot({fullPage?}) / click / type / evaluate / getContent / close; properties cdpUrl / liveUrl | CDP attached to a real Chromium (driven by Playwright); screenshot takes an object { fullPage?: boolean } and returns { base64Image } (the boolean form screenshot(true) is not a valid signature) |
| runCode ⭐ | sandbox.runCode(code, {language?, timeout?}) → {results, logs, error} | Jupyter kernel; variables persist across calls. ⚠️ This is a top-level method on context.sandbox — there is no code_interpreter namespace, so do not write sandbox.code_interpreter.runCode(...) |
| Control | getInfo() / extendTimeout(seconds) / kill() / envdAccessToken / getHost(port) | Inspect instance, extend lifetime, terminate |
// Inside an agent endpoint: use the injected sandbox directly
const result = await context.sandbox.commands.run('echo "hello"', { timeout: 10 }) // 10 seconds
await context.sandbox.files.write('/tmp/a.txt', 'utf8 content')
const shot = await context.sandbox.browser.screenshot({ fullPage: true }) // {base64Image}
const exec = await context.sandbox.runCode('print(1+1)', { language: 'python' }) // top-level method, {results, logs, error}
await context.sandbox.extendTimeout(900) // extend by 900 secondsresult = await ctx.sandbox.commands.run('echo "hello"', timeout=10)---
4. Debug Logging
- Off by default; when enabled, logs are emitted to stderr and do not enter the model context.
- Enable with the env var
MAKERS_AGENT_TOOLKIT_DEBUG=1(or in Pythonbuild_tools(..., debug=True)). - Automatically redacts token/auth/password/secret/key; screenshots only print summaries, not full base64.
---
5. Review Red Lines
- [ ] Agent endpoints use
context.sandbox/context.toolsdirectly, with no hand-written/v1/sandbox/*calls or manual token parsing - [ ] Template
.envdoes not require sandbox tickets / PROJECT_ID / SANDBOX_API_BASE / API_ENV (unless connecting via SDK directly) - [ ]
agents.frameworkinedgeone.jsonis set correctly (claude-agent-sdk/openai-agents-sdk/langgraph/deepagents/crewai— no `basic`) — required for console icon display - [ ] Claude SDK templates prefer
context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true })(recommended); manual assembly viaall()is also acceptable - [ ]
screenshotis called with an object:screenshot({ fullPage: true }), not the booleanscreenshot(true) - [ ]
runCodeis invoked as the top-levelsandbox.runCode(...), notsandbox.code_interpreter.runCode(...)(that namespace does not exist) - [ ] Binary / cached assets are generated inside the sandbox via
commands(base64 -d), not misused throughfiles.write - [ ] The non-persistent nature of sandbox
/tmp/is handled: the template wires up an in-process file cache plus re-upload on every request, and does not assume/tmp/is preserved - [ ] System prompt explicitly forbids the AI from fabricating files when it sees
FileNotFoundError - [ ] Timeout values are in seconds, not mistakenly milliseconds
- [ ]
extendTimeout(seconds)parameter is namedseconds, nots - [ ] For templates using
web_search(Python), the sandbox python env already has primp/httpx/h2/lxml installed - [ ] ⭐ Any template using the
web_searchtool (any language path) hasWSA_API_KEYconfigured in project environment variables
Memory / Store Cheat Sheet (Five Frameworks → context.store Adapters)
One-page reference: on EdgeOne Makers, which store entry point each Agent framework should use, how short-term/long-term memory is wired up, and how cloud-functions read it.
---
0. One-Sentence Mental Model
context.store/context.agent.storeis a conversation-storage wrapper, not a raw KV (noget/set/delete/list).- It ships official adapters for each framework — use them directly; do not roll your own `kvGet/kvSet`.
- Agent endpoints get
context.store(fullAgentMemory); cloud-functions getcontext.agent.store(withlanggraphCheckpointer/langgraphStorestripped out). The generic message API plus the openai/claude adapters are identical on both sides, but the langgraph adapters are only available on agent endpoints — see §1.
---
1. Two Entry Points (First Decide Which Directory the Endpoint Lives In)
Where is this endpoint built?
├─ agents/<name>/ → context.store ✅ All adapters (incl. langgraph*)
└─ cloud-functions/<name>/ → context.agent.store ⚠️ No langgraphCheckpointer / langgraphStore| Dimension | agent endpoint | cloud-function |
|---|---|---|
| Directory | agents/<name>/ | cloud-functions/<resource>/ |
| Entry point | context.store | context.agent.store |
Message API (appendMessage / getMessages etc.) | ✅ | ✅ |
Conversation metadata (getConversation / updateConversation etc.) | ✅ | ✅ |
openaiSession / claudeSessionStore | ✅ | ✅ |
langgraphCheckpointer / langgraphStore | ✅ | ❌ Explicitly stripped by the runtime |
⚠️ `context.store` is a conversation-oriented storage abstraction — designed for message history, session state, conversation metadata (title, tags, summary, user preferences). Bothcontext.store(agent endpoints) andcontext.agent.store(cloud-functions) point to the same data. It is NOT a general-purpose relational database — for complex queries, aggregation, or user management, use an external database.
⭐ Critical difference: a cloud-function'scontext.agent.storedoes not includelanggraphCheckpointer/langgraphStore— the runtime strips them. Callingstore.langgraphStore.get(...)inside a cloud-function throwsCannot read properties of undefined. Endpoints that need langgraph operations must live under `agents/` and usecontext.store.
---
2. Five-Framework Adapter Matrix (Core Cheat Sheet)
| Framework | Short-term memory | Long-term memory | Adapter access | Notes |
|---|---|---|---|---|
| Claude Agent SDK ⭐ | SDK session (resume/fork) | Store messages + metadata | context.store.claudeSessionStore() (no args) | Standalone usage, its own world — do not graft langgraph onto it |
| OpenAI Agents SDK | SDK Session (auto-prepend) | Store messages + metadata | context.store.openaiSession(convId) | Don't manually concatenate history |
| LangGraph | langgraphCheckpointer | langgraphStore | context.store.langgraphCheckpointer / .langgraphStore | Direct properties; thread_id = conversation_id |
| DeepAgents | Reuses LangGraph checkpointer | LangGraph store + filesystem | Same as LangGraph | Essentially LangGraph |
| **Bare model / custom loop | appendMessage/getMessages | Messages + metadata | Use the message API directly | Convert input via toOpenAIInput/toAnthropicMessages |
⭐ Why Claude SDK is in its own row: it goes through claudeSessionStore() to plug into the SDK's own session (resume/fork), which is a completely different mechanism from LangGraph's checkpointer+store pair. The multimodal template uses this route — don't conflate them during review.---
3. API Signature Essentials (single-object input — do not use two-arg form)
⚠️ Older examples mistakenly wrote the signature as getMessages(convId, { limit }). The actual signature takes a single-object input:
// ✅ Correct
await store.appendMessage({
conversationId: convId,
role: 'user',
content: 'hi',
metadata: { ... }, // optional
userId: 'u_123', // optional
});
const msgs = await store.getMessages({
conversationId: convId,
limit: 50, // 1~100
order: 'asc', // optional
after: cursor, // optional
before: cursor, // optional
});
await store.updateMessage({ conversationId, messageId, content: '...' });
await store.deleteMessage({ conversationId, messageId });
await store.clearMessages({ conversationId });Conversation metadata:
await store.getConversation(convId);
await store.updateConversation(convId, { metadata: { ... } }); // shallow merge
await store.listConversations({ limit: 20, after: cursor });
await store.deleteConversation(convId);Format conversion:
const oaInput = store.toOpenAIInput(msgs);
const anthropicMsgs = store.toAnthropicMessages(msgs);---
4. Copy-Paste Snippets
Bare model / custom loop — read & store history
const { store, conversation_id } = context;
const history = await store.getMessages({
conversationId: conversation_id,
limit: 50,
});
const modelInput = store.toOpenAIInput(history);
await store.appendMessage({
conversationId: conversation_id,
role: 'user',
content: body.message,
});
await store.appendMessage({
conversationId: conversation_id,
role: 'assistant',
content: finalText,
});Claude Agent SDK — Route B
const sessionStore = context.store.claudeSessionStore(); // no args
// Wires into Claude SDK session persistence; multi-user is keyed by conversation_id, reuse via resumeOpenAI Agents SDK
import { run, Agent } from '@openai/agents';
const session = context.store.openaiSession(context.conversation_id);
const agent = new Agent({ name: 'Assistant', instructions: '...', tools, model });
const result = await run(agent, message, { stream: true, session, signal });LangGraph / DeepAgents
const checkpointer = context.store.langgraphCheckpointer; // direct property
const lgStore = context.store.langgraphStore; // direct property
const graph = workflow.compile({ checkpointer, store: lgStore });
await graph.invoke(input, { configurable: { thread_id: context.conversation_id } });cloud-function — regular endpoint
export async function onRequest(context: any) {
const store = context.agent?.store; // ⚠️ not context.store
if (!store) return Response.json({ ok: false });
const conversationId = context.request.body?.conversation_id || '';
// ✅ Read conversation history (for display in frontend)
const messages = await store.getMessages({ conversationId, limit: 50, order: 'asc' });
return Response.json({ conversation_id: conversationId, messages });
// ❌ Do NOT use the langgraph adapters inside a cloud-function:
// the runtime has explicitly stripped them; store.langgraphStore === undefined
// Endpoints that need langgraph KV must live under agents/<name>/ and use context.store.
return Response.json({ ok: true, prefs });
}---
5. Limits Cheat Sheet
| Item | Value |
|---|---|
getMessages limit | 1 ~ 100 |
| Max messages per conversation | 10,000 |
| Max content size per message | 50MB |
langgraphStore.search | No vector search; score is always undefined |
updateConversation metadata | Shallow merge (top-level overwrite) |
appendMessage / getMessages signature | Single-object input { conversationId, ... } |
---
6. Choosing the Right Storage
context.store is one of several storage options available on EdgeOne Makers. Choose based on your data type:
Storage Decision Guide
| Data type | Recommended storage | Why |
|---|---|---|
| Conversation history / messages | context.store | Built-in, zero-config, designed for this |
| Agent session state / checkpoints | context.store (langgraphCheckpointer) | Framework-native integration |
| Conversation metadata (title, summary, tags) | context.store (updateConversation) | Lightweight, per-conversation |
| Simple key-value pairs | EdgeOne Makers KV | Fast, edge-distributed, string values |
| File / blob storage (images, PDFs, large objects) | EdgeOne Makers Blob | Object storage, supports large files |
| Structured business data (users, products, orders) | External database (Supabase, Neon, PlanetScale, etc.) | Relational queries, indexes, joins |
| Vector / semantic search | External vector store (Supabase pgvector, Pinecone, etc.) | langgraphStore.search has no vector search |
Default behavior for AI assistants
Default: use `context.store` to implement the feature first. After implementation is complete, inform the user about alternative storage options if their use case may benefit from them:
✅ Done! I've implemented this using context.store (the platform's built-in conversation storage).>
FYI: if your project grows to need more advanced storage, EdgeOne Makers also offers:
- [KV](https://cloud.tencent.com/document/product/1552/127420) — edge-distributed key-value store (fast reads, simple data)
- [Blob](https://cloud.tencent.com/document/product/1552/131425) — object/file storage (images, PDFs, large files)
- Supabase / external DB — for relational data with queries, indexes, and joins
context.store Fit Boundaries
| Use case | Fit? | Notes |
|---|---|---|
| Conversation/dialog history (≤10,000 messages) | ✅ | Primary use case |
| Agent execution state / thread snapshots | ✅ | langgraphCheckpointer |
| Conversation-level metadata (summary, preferences) | ✅ | updateConversation metadata |
| Simple key-value within langgraph | ✅ | langgraphStore.get/put |
| Structured queries (WHERE, JOIN, ORDER BY) | ❌ | Use external database |
| Semantic / full-text search | ❌ | Use external vector store |
| Large files / binaries | ❌ | Use Blob storage |
Key cross-framework sharing rule: each adapter (openaiSession / claudeSessionStore / langgraphStore / generic appendMessage) writes into its own namespace and cannot see the others. To share data across frameworks, pick one entry point as the source of truth. Do not expect adapters to interoperate automatically.
---
Python Store API (Route E and future Python routes)
The Python runtime provides the same ctx.store (ConversationMemory) with identical data layout, but uses Python naming conventions:
Node ↔ Python Method Mapping
| Node (TS) | Python | Notes |
|---|---|---|
store.appendMessage({ conversationId, role, content, metadata }) | await ctx.store.append_message(conversation_id, role, content, metadata=None) | Positional args (not single-object) |
store.getMessages({ conversationId, limit, order }) | await ctx.store.get_messages(conversation_id, limit=20, order="asc") | Default ascending |
store.updateMessage({ messageId, content, metadata }) | await ctx.store.update_message(message_id, content=..., metadata=...) | |
store.deleteMessage({ messageId }) | await ctx.store.delete_message(message_id) | |
store.clearMessages({ conversationId }) | await ctx.store.clear_messages(conversation_id) | |
store.getConversation(id) | await ctx.store.get_conversation(conversation_id) | |
store.updateConversation(id, { metadata }) | await ctx.store.update_conversation(conversation_id, metadata={}) | Shallow merge |
store.listConversations({ limit, order }) | await ctx.store.list_conversations(limit=20, order="desc") | |
store.deleteConversation(id) | await ctx.store.delete_conversation(conversation_id) | |
store.toOpenAIInput(messages) | ctx.store.to_openai_input(messages) | Sync (no await) |
store.toAnthropicMessages(messages) | ctx.store.to_anthropic_messages(messages) | Sync (no await) |
store.langgraphCheckpointer | ctx.store.langgraph_checkpointer | Direct property (snake_case) |
store.langgraphStore | ctx.store.langgraph_store | Direct property (snake_case) |
Python Example
async def handler(ctx):
# Append user message
msg_id = await ctx.store.append_message(ctx.conversation_id, "user", "Hello!")
# Get history (ascending = oldest first, ready for prompt)
messages = await ctx.store.get_messages(ctx.conversation_id, limit=50)
# Convert to OpenAI format for model input
openai_msgs = ctx.store.to_openai_input(messages)
# LangGraph adapters (same rules: only in agent endpoints, not cloud-functions)
checkpointer = ctx.store.langgraph_checkpointer
lg_store = ctx.store.langgraph_store⚠️ Same constraint applies:ctx.store.langgraph_checkpointerandctx.store.langgraph_storeare only available in agent endpoints (agents/<name>/). The Python runtime applies the same stripping logic for cloud-function endpoints.
---
7. Review Red Lines (Spot Issues in 5 Seconds)
- [ ] Agent endpoints use
context.store, cloud-functions usecontext.agent.store— is the entry point correct? - [ ] Are
appendMessage/getMessagescalled with single-object input, not(convId, options)two args? - [ ] Does Claude SDK use
claudeSessionStore()(no args), and not mistakenly graft langgraph onto it? - [ ] No home-rolled
kvGet/kvSetsimulating KV viaclearMessages+appendMessage? - [ ] No pseudo-fallback like
store?.langgraphStore ?? store? (Inside a cloud-function,langgraphStoreis undefined; the fallback hands back the store itself, and the next.getcall will crash.) - [ ] Is history stored as multiple
appendMessagerecords, not stuffed into a single message's content field? - [ ] Is the model fed via
toOpenAIInput/toAnthropicMessages, not a hand-built array? - [ ] Business data (user profiles, settings, files) is stored in an external database — NOT crammed into
context.store? - [ ] No process-local
new Map()cache mistaken for a persistence layer? - [ ] ⚠️ Endpoints that need
langgraphStore.get/put/deleteare placed under `agents/`, not accidentally dropped intocloud-functions/(the runtime strips langgraph adapters there)? - [ ] Structured business data that needs querying / sorting / aggregation has not been crammed into the store (that's MySQL's job)?
- [ ] You are not relying on
langgraphStore.searchfor semantic / full-text retrieval (it has no vector search)? - [ ] When sharing data across frameworks, a single entry point has been chosen as the source of truth — no expectation that different adapter namespaces will interoperate automatically?
Tools Registry (context.tools)
Covers: ToolsContext interface, agents.framework-driven shape, 5-framework integration, built-in tools inventory.
---
2. Tools Registry (context.tools)
context.tools is built by toolkit.buildTools(framework, sandbox) during lazy load, and its shape is determined by `agents.framework` in `edgeone.json`.
2.1 ToolsContext Interface (@edgeone/pages-agent-toolkit)
interface ToolsContext {
// —— Flat per-operation tool properties ——
readonly commands: FrameworkTool;
readonly files_read: FrameworkTool;
readonly files_write: FrameworkTool;
readonly files_list: FrameworkTool;
readonly files_exists: FrameworkTool;
readonly files_remove: FrameworkTool;
readonly files_make_dir: FrameworkTool;
readonly browser_fetch: FrameworkTool;
readonly browser_screenshot: FrameworkTool;
readonly browser_click: FrameworkTool;
readonly browser_type: FrameworkTool;
readonly browser_evaluate: FrameworkTool;
readonly code_interpreter: FrameworkTool;
readonly web_search: FrameworkTool;
// —— Direct access methods ——
all(): FrameworkTool[];
get(name: string): FrameworkTool | undefined;
files(): FrameworkTool[];
browser(): FrameworkTool[];
// —— Framework conversion helpers ——
toLangChainTools(toolFactory, names?): FrameworkTool[]; // inject LangChain tool() factory
toCrewAITools(baseTool, names?): FrameworkTool[]; // inject CrewAI BaseTool class
toClaudeMcpServer(name?, options?): ClaudeMcpServerBundle; // { name, tools, allowedTools }
}ThetoLangChainToolsandtoCrewAIToolshelpers allow templates to inject the framework class/factory at call time, so the toolkit itself does not depend on LangChain or CrewAI. In most cases,all()is sufficient since tools are already pre-adapted based onagents.framework.
2.2 Three Types of Tool Access
context.tools / ctx.tools provides three categories of access methods:
| Category | Methods | Usage |
|---|---|---|
| Direct tools | all(), get(name), files(), browser() | Returns tools pre-adapted for the current framework. Most frameworks just use all() directly. |
| Claude MCP helper | toClaudeMcpServer(name?, options?) / to_claude_mcp_server(...) | Generates { name, tools, allowedTools } for Claude Agent SDK MCP server registration |
| LangChain helper | toLangChainTools(toolFactory, names?) | Injects LangChain tool() factory. Used by LangGraph / DeepAgents. |
| CrewAI helper | toCrewAITools(baseTool, names?) | Injects CrewAI BaseTool class. Used by CrewAI. |
Why pass a factory? The platform toolkit doesn't bundle@langchain/coreorcrewaidirectly (to avoid version conflicts with your project). Instead, you pass your project'stoolfunction orBaseToolclass, and the toolkit uses it to construct real framework-native tool instances.
OpenAI Agents SDK has no dedicated `toXXX` helper —all()already returns tools in OpenAI function format ({ type:'function', name, parameters, execute }), ready to pass tonew Agent({ tools }).
2.3 Framework-Specific Tool Wiring
⚠️ Different frameworks require different methods to get tools. Using the wrong method may result in tools that don't work (e.g., duck-type objects that fail instanceof checks).
agents.framework | ⭐ Recommended method | Why |
|---|---|---|
claude-agent-sdk | context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true }) | Returns { name, tools, allowedTools } bundle for MCP registration |
openai-agents-sdk | context.tools.all() | Returns real OpenAI function tool objects, ready to use |
langgraph | context.tools.toLangChainTools(tool) | ⚠️ Must inject LangChain tool factory to produce real StructuredTool instances. all() returns duck-type objects that may fail. |
deepagents | context.tools.toLangChainTools(tool) | ⚠️ Same as LangGraph — DeepAgents expects real LangChain tools. |
crewai | context.tools.toCrewAITools(BaseTool) | ⚠️ Must inject CrewAI BaseTool class to produce real CrewAI tool instances. |
2.4 Code Examples Per Framework
Claude Agent SDK (Node):
// Recommended: toClaudeMcpServer
const bundle = context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true });
const mcp = createSdkMcpServer(bundle);
query({ prompt, options: { mcpServers: { [bundle.name]: mcp }, allowedTools: bundle.allowedTools } });OpenAI Agents SDK (Node):
// all() works directly — tools are already in OpenAI function format
const tools = context.tools.all();
const agent = new Agent({ name: 'Assistant', tools, model });LangGraph / DeepAgents (Node):
import { tool } from '@langchain/core/tools';
// ⭐ Must use toLangChainTools — inject the LangChain tool factory
const tools = context.tools.toLangChainTools(tool);
const modelWithTools = model.bindTools(tools);
const toolNode = new ToolNode(tools);CrewAI (Python):
from crewai import BaseTool
# ⭐ Must use toCrewAITools — inject the CrewAI BaseTool class
tools = ctx.tools.to_crewai_tools(BaseTool)
agent = Agent(role="...", tools=tools, llm=llm)LangGraph / DeepAgents (Python):
from langchain_core.tools import tool
# ⭐ Must use toLangChainTools
tools = ctx.tools.to_langchain_tools(tool)
model_with_tools = model.bind_tools(tools)
tool_node = ToolNode(tools)2.5 Getting a Single Tool / Group
// Node
const search = context.tools.get('web_search'); // single tool or undefined
const fileTools = context.tools.files(); // [files_read, files_write, ...]
const browserTools = context.tools.browser(); // [browser_fetch, browser_screenshot, ...]# Python
search = ctx.tools.get("web_search")
file_tools = ctx.tools.files()
browser_tools = ctx.tools.browser()| Tool | Parameters | Notes |
|---|---|---|
commands | cmd, cwd, env, timeout | One-shot shell; also used to download/generate binaries |
files_read / files_write / files_list / files_exists / files_remove / files_make_dir | path (write also takes content) | Text-file CRUD; use commands for binary |
browser_fetch / browser_screenshot / browser_click / browser_type / browser_evaluate | varies | Real Chromium |
code_interpreter | language, code, timeout | Python/JS/R/Bash execution |
web_search | query, maxResults, site | Tencent Cloud WSA search API; does NOT use sandbox — calls WSA API directly. ⚠️ Requires `WSA_API_KEY` env var. Supports optional site for domain-restricted search. |
⭐ web_search Configuration Requirements
If a template uses context.tools.web_search (or pulls this tool via context.tools.all() / get('web_search')), WSA_API_KEY must be configured in the project environment variables.
| Item | Description |
|---|---|
| Env var name (EdgeOne Makers) | WSA_API_KEY |
| Upstream service | Tencent Cloud Web Search API (product 1806) — a standalone service, separate product from EdgeOne |
| Console | <https://console.cloud.tencent.com/wsapi/index> |
| Where to configure | EdgeOne project environment variables (same level as AI_GATEWAY_API_KEY) |
| How template code reads it | No explicit reference needed — the toolkit's SimpleSearch class reads WSA_API_KEY from process.env at call time (this is an exception to the "no process.env" rule — the toolkit itself is allowed to read it) |
| Failure symptom | web_search calls fail with 401 / auth errors |
Steps to Obtain
1. Open the Tencent Cloud Web Search API console 2. Overview page → "Service API KEY" section → click "Create API KEY" 3. Enter a key name → confirm → download the CSV or copy immediately (cannot be viewed again after closing the dialog) 4. Back in your EdgeOne project → environment variables → add WSA_API_KEY = the value you just copied 5. Redeploy; context.tools.web_search will now work
⚠️ Naming convention difference: in the Tencent Cloud Web Search API official docs, the env var is namedTENCENTCLOUD_WSA_APIKEY(used when calling their SDK directly); in EdgeOne Makers' sandbox runner, the convention is `WSA_API_KEY`. Both point to the same key — only the injection location differs. Just setWSA_API_KEYin your EdgeOne project; you do not need to setTENCENTCLOUD_WSA_APIKEYseparately.
Template self-check:
- If you only do plain LLM text generation (no search tool) →
WSA_API_KEYis not needed - If your code uses
context.tools.get('web_search')/context.tools.all()→WSA_API_KEYmust be configured
web_search Return Value
Returns an array of SearchResult objects:
interface SearchResult {
title: string; // Result page title
href: string; // Canonical destination URL
snippet: string; // Text excerpt / passage (not full page content)
site: string; // Source website name (may be empty)
date: string; // Publication date (may be empty)
}Example response:
[
{
"title": "EdgeOne Makers Documentation",
"href": "https://edgeone.ai/docs/pages",
"snippet": "EdgeOne Makers is a full-stack deployment platform...",
"site": "edgeone.ai",
"date": "2026-05-01"
},
{
"title": "Getting Started with EdgeOne",
"href": "https://cloud.tencent.com/document/product/1552",
"snippet": "Quick start guide for EdgeOne acceleration...",
"site": "cloud.tencent.com",
"date": ""
}
]web_search Input Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
query | string | ✅ | Search query text |
maxResults | integer | ❌ | Max results to return (default 5, must be positive integer) |
site | string | ❌ | Restrict to a single domain, e.g. "zhihu.com" |
web_search vs browser_* — when to use which
| Scenario | Tool |
|---|---|
| Open-ended search (news, docs, discovery) | web_search |
| Known URL needing DOM / screenshot / interaction | browser_* |
| Direct JSON/API endpoints | commands or code_interpreter |
web_searchreturns structured results (title, href, snippet, site, date). It does NOT load pages, execute JS, or return full HTML — usebrowser_fetchfor that.
---
Native Code Patterns Across Five Frameworks (Migration Reference)
⚠️ Purpose: This document shows the official native patterns for each Agent framework (i.e. what they look like without EdgeOne Makers injection).
Do NOT copy these patterns into Makers templates — on Makers, models go through thecontext.envgateway, tools come fromcontext.tools, and storage comes fromcontext.store.
Use this file to: ① understand the native shape of each framework, ② see by contrast what Makers injection saves you, ③ help teammates migrate from native usage to Makers.
For the actual Makers patterns you should write, seelangchain-route.md,claude-sdk-route.md, andplatform-conventions.md.
---
0. Framework Positioning and Officially Recommended Path
| Framework | Positioning | Onboarding Priority |
|---|---|---|
| DeepAgents | A batteries-included harness on top of LangGraph: automatic context compaction, virtual FS, sub-agents | ⭐ Top pick for getting started |
LangChain createAgent | High-level wrapper with automatic tool loop + middleware | Second choice |
| LangGraph | Low-level graph orchestration: Persistence / HITL / Streaming / Durable | Drop down only for complex scenarios |
| OpenAI Agents SDK | Lightweight Agent runtime (Swarm successor): Handoff / Guardrails | Multi-agent collaboration |
| Claude Agent SDK | Anthropic Messages API + Tool Use, the most direct path | Edge-friendly |
Officially recommended order: DeepAgents (highest level) → LangChain createAgent → LangGraph (lowest level), dropping down only as needed.
---
1. LangGraph
Core conventions
- Python: define State with
TypedDict + Annotated Reducer; production checkpointer must beAsyncPostgresSaver—MemorySaveris for demos only - TypeScript: use
MessagesAnnotation(built-in reducer), cleaner than hand-written TypedDict - Stream:
streamMode: "messages"streams tokens;streamMode: "updates"streams node-state updates - Must use
runtime = 'nodejs'— Edge Runtime is not supported
// Native pattern (@langchain/langgraph)
import { StateGraph, MessagesAnnotation, START } from '@langchain/langgraph'
import { ToolNode } from '@langchain/langgraph/prebuilt'
import { MemorySaver } from '@langchain/langgraph'
const modelWithTools = model.bindTools(tools)
const toolNode = new ToolNode(tools)
async function agentNode(state: typeof MessagesAnnotation.State) {
return { messages: [await modelWithTools.invoke(state.messages)] }
}
function shouldContinue(state: typeof MessagesAnnotation.State): 'tools' | '__end__' {
const last = state.messages[state.messages.length - 1]
return ('tool_calls' in last && (last.tool_calls as any[]).length) ? 'tools' : '__end__'
}
const graph = new StateGraph(MessagesAnnotation)
.addNode('agent', agentNode).addNode('tools', toolNode)
.addEdge(START, 'agent').addConditionalEdges('agent', shouldContinue).addEdge('tools', 'agent')
.compile({ checkpointer: new MemorySaver() }) // Swap in PostgresSaver for production→ How the Makers version differs: the model goes through thecontext.envgateway; tools come fromcontext.tools.all()(after settingagents.framework: 'langgraph'or'deepagents'inedgeone.json); checkpointer/store come fromcontext.store.langgraphCheckpointer/context.store.langgraphStore(direct properties); thread_id = conversation_id.
---
2. OpenAI Agents SDK
Core conventions
- Decorate tools with
@function_tool(Python); the docstring becomes the description automatically - Run guardrails in parallel (with Pydantic structured output) — keep them out of the main Agent loop
- Use Handoff for multi-agent collaboration
- Use a Session for multi-turn conversations (Python
SqlAlchemySession); do not stitch history manually
from agents import Agent, Runner, handoff, function_tool, input_guardrail
@function_tool
def search_web(query: str) -> str:
"""Search the web for information about a given topic."""
return f"Search results for: {query}"
billing_agent = Agent(name="Billing", instructions="Handle billing.", tools=[search_web])
triage_agent = Agent(
name="Triage", instructions="Route to specialist.",
handoffs=[handoff(billing_agent, tool_name_override="to_billing")],
input_guardrails=[safety_check],
)// New in 2026: @openai/agents (run a full Agent directly in Node)
import { Agent, Runner } from '@openai/agents'
const agent = new Agent({ name: 'Assistant', instructions: '...', tools, model })
// Streaming: Runner.runStreamed().streamEvents()
// event.type === 'run_item_stream_event' → text output
// event.type === 'agent_updated_stream_event' → Handoff switch→ How the Makers version differs: tools come fromcontext.tools.all()(withagents.framework='openai-agents-sdk'); session comes fromcontext.store.openaiSession(conversationId), which auto-prepends history; env goes throughcontext.env— never readprocess.env.
---
3. CrewAI
Core conventions
- Configure Agent/Task in YAML (with the
@CrewBasedecorator) — do not hard-code in Python - Set
max_iterations(default 15; lower it in production) - For long-running tasks, use
kickoff_async()+ an async job + polling - ⚠️ CrewAI has no official JS SDK — it is Python-only, with no native Node option
# config/agents.yaml — keep role descriptions clear and specific; use {topic} for dynamic interpolation
# crew.py
from crewai import Agent, Crew, Task
from crewai.project import CrewBase, agent, task, crew
@CrewBase
class ResearchCrew:
@agent
def researcher(self) -> Agent:
return Agent(config=self.agents_config['researcher'], verbose=True)
@crew
def crew(self) -> Crew:
return Crew(agents=self.agents, tasks=self.tasks, memory=True) # memory=True for unified memory→ How the Makers version differs: tools come fromcontext.tools.all()(withagents.framework='crewai'); memory uses CrewAI's built-inmemory=True; because there is no JS SDK, CrewAI templates require a Python runtime.
---
4. DeepAgents / LangChain createAgent
Core conventions
- Top pick for getting started;
create_deep_agent(Python) handles context compaction automatically — no manualtrim_messages - In TypeScript, use
createAgentfromlangchain— simpler than going straight to LangGraph.js - Extend behavior via middleware (logging / guardrail); each middleware should do exactly one thing
- LangSmith: set
LANGSMITH_TRACING=truefor automatic tracing
from deepagents import create_deep_agent
from langchain_anthropic import ChatAnthropic
agent = create_deep_agent(model=ChatAnthropic(model="claude-sonnet-4-5"), tools=[...])
# Streaming: agent.astream_events(..., version="v2") → event["event"]=="on_chat_model_stream"import { createAgent, tool } from 'langchain'
const agent = createAgent({
model: 'anthropic/claude-sonnet-4-5', tools: [getWeather],
middleware: [loggingMiddleware(), guardrailMiddleware({ maxOutputLength: 4096 })],
})→ How the Makers version differs: seelangchain-route.md; the model goes through the gateway (context.env), tools come fromcontext.tools.all()(withagents.framework='deepagents'or'langgraph'), and memory reuses the LangGraph adapters (direct propertieslanggraphCheckpointer/langgraphStore).
---
5. Claude Agent SDK
Core conventions
- Anthropic Messages API + Tool Use is the most direct way to build an Agent on the edge
- ⚠️
claude-codeitself is a CLI and is not suitable for direct deployment; implement logic with@anthropic-ai/claude-agent-sdk - Use the SDK session (resume/fork) for multi-turn conversations
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
// Tool Use multi-turn loop: call messages.create → check stop_reason === 'tool_use'
// → execute the tool → append tool_result back into messages → call again, until no more tool_use→ How the Makers version differs: seeclaude-sdk-route.md; after settingagents.framework: 'claude-agent-sdk'inedgeone.json, the recommended way to wire tools iscontext.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true })(returns{name,tools,allowedTools}— a Claude SDK-specific capability), or feedcontext.tools.all()intocreateSdkMcpServer({ name, tools, alwaysLoad: true }); session comes fromcontext.store.claudeSessionStore()(no arguments — ⭐ standalone usage; do not wrap it with langgraph).
---
6. Native → Makers Injection Cheat Sheet
| Aspect | Framework Native | EdgeOne Makers Injected |
|---|---|---|
| Model | You instantiate new ChatAnthropic() / new Anthropic() yourself | Injected via the context.env AI Gateway (do not use process.env) |
| Tools | You define tools yourself / @function_tool | context.tools.all() (first set agents.framework in edgeone.json — that determines tool shape) |
| Sandbox | You spin up containers/processes yourself | context.sandbox (commands/files/browser/code_interpreter) |
| Short-term memory | checkpointer / SDK session | The matching adapter on context.store |
| Long-term memory | PostgresSaver / store | context.store.langgraphStore / conversation metadata |
| Entry point | app/api/route.ts + POST(req) | agents/<name>/ + onRequest(context) |
| Streaming | You assemble SSE yourself | createSSEResponse from _shared.ts + a unified event protocol |
| Route registration | You maintain config yourself | Auto-scanned by the CLI at build time — no manual maintenance needed |
Route B: Claude Agent SDK (@anthropic-ai/claude-agent-sdk)
Use when: multi-step agentic flows, sandbox code execution, file processing, session memory.
Core pattern: query() + dual MCP servers (sandbox + custom tools) + session binding + SSE side channel.---
Dependencies
npm install @anthropic-ai/claude-agent-sdk zodedgeone.json:
{
"agents": {
"framework": "claude-agent-sdk"
}
}@anthropic-ai/claude-agent-sdkis auto-externalized by the CLI — no manualexternalNodeModulesconfig needed.
---
When to Use Route B
✅ Good fit:
- Need a sandbox to run code (Python/shell) and process uploaded files
- Need multi-turn session memory (resume session)
- Need custom MCP tools (e.g.
suggest_actions,deliver_file) - Complex multi-step agentic reasoning
❌ Not a fit:
- Plain text generation only → DeepAgents is simpler
---
Core Pattern Walkthrough
1. Gateway env mapping (from _model.ts)
See resolveModelName + collectGatewayEnv in `node-entry.md` §3. Key points:
- Map
AI_GATEWAY_*to theANTHROPIC_*variables the SDK expects - Return a
Recordand inject it viaquery()'soptions.env. Do not read `process.env` — agent endpoints disableprocess.env; always go throughcontext.env. - ⚠️ Must include writable config directories — the Claude CLI subprocess requires a writable
~/.claudeand temp directory. In the EdgeOne Makers serverless runtime, HOME is typically not writable — the SDK silently exits with zero output if it cannot initialise its config directory.
const queryEnv = {
...collectGatewayEnv(ctxEnv),
CLAUDE_CONFIG_DIR: '/tmp/claude-agent-sdk', // writable config directory
CLAUDE_CODE_TMPDIR: '/tmp', // writable temp directory
};
// Pass to query({ options: { env: queryEnv, ... } })2. Defensive initialisation
import { query, createSdkMcpServer, getSessionInfo } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { resolveModelName, collectGatewayEnv } from '../_model';
import { createLogger, sseEvent, createSSEResponse } from '../_shared';
const logger = createLogger('chat');
// Prevent the SDK's stdout observability from crashing the process on EPIPE
process.stdout.on('error', (err: any) => {
if (err.code === 'EPIPE') return;
});Principle: the Claude Agent SDK writes to stdout internally, and on EdgeOne the pipe may close early — you must swallow EPIPE.
3. Session binding (conversation memory)
/** Normalise an arbitrary conversationId into a valid UUID */
function normalizeUuid(id: string): string | null {
if (!id) return null;
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidRe.test(id)) return id.toLowerCase();
const hex = id.replace(/[^0-9a-f]/gi, '').padEnd(32, '0').slice(0, 32);
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`;
}
/** Resume an existing session or start a new one */
async function resolveClaudeSessionBinding(
sessionStore: any, conversationId: string, cwd: string
): Promise<{ resume?: string; sessionId?: string }> {
const sessionId = normalizeUuid(conversationId);
if (!sessionId) return {};
try {
const infoOptions: any = { dir: cwd };
if (sessionStore?.load) infoOptions.sessionStore = sessionStore;
const info = await getSessionInfo(sessionId, infoOptions);
if (info) return { resume: sessionId }; // resume
} catch { /* store unavailable */ }
return { sessionId }; // new session
}Principle: takeconversation_idfromcontext.conversation_id, falling back to themakers-conversation-idheader.
>
Important: Claude SDK has its own session/resume/forkmechanism viacontext.store.claudeSessionStore()(no-arg — unique to the Claude SDK). Do not mix this with a langgraph checkpointer — the two state models are incompatible. See `langgraph.md` for the langgraph-style alternative.
4. Sandbox readiness probe + file upload (with cold-start retry)
// Sandbox may cold-start; probe with retry
let sandboxWorking = false;
if (sandbox) {
try {
await sandbox.commands.run('ls /tmp', { timeout: 10 });
sandboxWorking = true;
} catch {
for (let attempt = 0; attempt < 2; attempt++) {
await new Promise(r => setTimeout(r, 2000));
try {
await sandbox.commands.run('ls /tmp', { timeout: 10 });
sandboxWorking = true; break;
} catch { /* retry */ }
}
}
}
// File upload: strategy 1 (files.write + base64 -d) → strategy 2 (Python decode, supports chunking)
// See template _tools.ts for details. Key points:
// - Small files: write base64 with files.write, then `base64 -d` in shell
// - Large files (>150KB): chunk write + Python decode
// - All strategies fail → degrade to inline-text modeSee `sandbox.md` for the full upload strategy reference.
5. File cache (working around the ephemeral sandbox)
// Sandbox /tmp is ephemeral and lost between requests. Use a process-level
// cache and re-upload on every request.
const _sessionFileCache = new Map<string, Array<{ name: string; base64: string }>>();
// Merge new files with the cache (same-name overwrites)
if (conversationId && uploadedFiles.length > 0) {
const mergedMap = new Map(cachedSessionFiles.map(f => [f.name, f]));
uploadedFiles.forEach(f => mergedMap.set(f.name, f));
_sessionFileCache.set(conversationId, Array.from(mergedMap.values()));
}⚠️ Critical: the sandbox /tmp/ is per-request and easily lost — there is no shared persistent FS between invocations. Route B templates must keep a process-level file cache and re-upload on every request. Even follow-up requests that carry no new files must re-upload everything previously cached for that conversation.6. Custom MCP server (SSE side-channel pattern)
// Key trick: tool handlers push events into sseQueue; the main loop drains
// the queue and yields after each step.
const sseQueue: string[] = [];
const customMcpServer = createSdkMcpServer({
name: 'custom-tools',
alwaysLoad: true,
tools: [
{
name: 'suggest_actions',
description: 'Present clickable action options to the user after analysing files.',
inputSchema: {
actions: z.array(z.object({
id: z.string(), emoji: z.string(),
title: z.string(), description: z.string(),
})),
},
handler: async ({ actions }: { actions: any[] }) => {
sseQueue.push(sseEvent({ type: 'suggest_actions', actions }));
return { content: [{ type: 'text' as const, text: 'Suggestions displayed. Wait for user choice.' }] };
},
},
{
name: 'deliver_file',
description: 'Deliver a processed file to the user for download.',
inputSchema: {
path: z.string(), filename: z.string(),
description: z.string().optional(),
},
handler: async ({ path, filename, description }: any) => {
let base64 = '';
try {
if (sandbox?.commands?.run) {
const r = await sandbox.commands.run(`base64 -w 0 ${shellQuote(path)}`);
base64 = (r.stdout || '').trim();
}
} catch (e) {
return { content: [{ type: 'text' as const, text: `Error reading file: ${(e as Error).message}` }] };
}
if (!base64) return { content: [{ type: 'text' as const, text: `File not found: ${path}` }] };
sseQueue.push(sseEvent({ type: 'file_output', base64, filename, description: description ?? '' }));
return { content: [{ type: 'text' as const, text: `File "${filename}" delivered.` }] };
},
},
],
});Core trick: MCP tools cannot write to the HTTP stream directly. Use thesseQueuearray as a side channel — tool handlers push to it, and the mainquery()loop drains and yields the queue after each step.
7. Assembling the query() main loop (with dual MCP servers)
export async function onRequest(context: any) {
// ⭐ Always read env from context.env, never process.env
const ctxEnv = context.env ?? {};
const body = context.request.body ?? {};
const message = typeof body.message === 'string' ? body.message.trim() : '';
if (!message) {
return new Response(JSON.stringify({ error: "'message' is required" }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
const signal = context.request.signal;
const conversationId = context.conversation_id;
const sandbox = context.sandbox ?? null;
const store = context.store ?? null;
// ... sandbox probe + file upload (with module-level cache, re-upload each time)
// + session binding (see above)
// ⭐ Use toClaudeMcpServer to get the MCP bundle (name + tools + allowedTools)
const edgeoneBundle = context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true });
const edgeoneMcpServer = createSdkMcpServer(edgeoneBundle);
async function* run(sig?: AbortSignal): AsyncGenerator<string> {
const sessionBinding = await resolveClaudeSessionBinding(store, conversationId, process.cwd());
const stream = query({
prompt: message,
options: {
model: resolveModelName(ctxEnv),
env: {
...collectGatewayEnv(ctxEnv),
CLAUDE_CONFIG_DIR: '/tmp/claude-agent-sdk',
CLAUDE_CODE_TMPDIR: '/tmp',
},
maxTurns: 30,
mcpServers: {
edgeone: edgeoneMcpServer,
'custom-tools': customMcpServer,
},
allowedTools: edgeoneBundle.allowedTools,
...sessionBinding,
abortController: sig ? { signal: sig } as any : undefined,
},
});
for await (const msg of stream) {
if (sig?.aborted) break;
// First, drain SSE events pushed by custom tools
while (sseQueue.length) yield sseQueue.shift()!;
// Dispatch by msg.type:
// 'text' → ai_response (streaming text delta)
// 'tool_use' → tool_call (model wants to call a tool)
// 'tool_result' → tool_result (tool execution completed)
// 'usage' → usage stats (input/output tokens)
// 'error' → error_message
// ...
}
while (sseQueue.length) yield sseQueue.shift()!;
yield 'data: [DONE]\n\n';
}
return createSSEResponse(run, signal);
}⭐ About `context.tools.toClaudeMcpServer()`: returns{ name, tools, allowedTools }(whereallowedToolslooks likemcp__edgeone__commands). This is the required way to wire platform tools on the Claude SDK route:
```typescript
const bundle = context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true });
const mcp = createSdkMcpServer(bundle);
query({ prompt, options: { mcpServers: { [bundle.name]: mcp }, allowedTools: bundle.allowedTools } });
```
Prerequisite: setagents.framework: "claude-agent-sdk"inedgeone.json.
8. Degrading when the sandbox is unavailable
// Sandbox down → inline text-like files directly into the message
if (!sandboxWorking && uploadedFiles.length > 0) {
let inlineContent = '\n\n--- FILE CONTENTS (sandbox unavailable) ---\n';
for (const file of uploadedFiles) {
const content = Buffer.from(file.base64, 'base64');
if (canInlineFallbackFile(file.name, content)) { // text-like only, no heavy binary noise
inlineContent += `\n### File: ${file.name}\n\`\`\`\n${content.toString('utf8')}\n\`\`\`\n`;
}
}
message = message + inlineContent;
}Principle: the sandbox is best-effort. Text files can degrade to inline content; for binaries that can't degrade, tell the model explicitly that they were skipped.
Beyondsandbox.runCode(...)(top-level) andsandbox.commands.run(...), the Claude SDK route can also usescreenshot({ fullPage: true }),context.tools.files(), andcontext.tools.browser()— see `sandbox.md`.
---
Route B Review Checklist
- [ ]
process.stdoutEPIPE swallowed - [ ]
query()hasmaxTurnsset - [ ] env injected via
collectGatewayEnv(context.env), never `process.env` - [ ] Sandbox probe includes cold-start retry
- [ ] File upload has multiple strategies + fallback to inline text
- [ ] Cross-request files use a process-level cache + re-upload every time (sandbox
/tmp/is easily lost) - [ ] Custom tools use the
sseQueueside channel; main loop drains it - [ ] Sessions normalised with
normalizeUuid+getSessionInfoto decide resume/new - [ ] AbortSignal forwarded to
query()and checked inside the loop - [ ]
context.request.headersaccessed viaheaders['x-foo']indexing, not.get('x-foo') - [ ]
edgeone.jsonhasagents.framework: "claude-agent-sdk" - [ ] ⭐ Tools wired with
context.tools.toClaudeMcpServer('edgeone', { alwaysLoad: true })→createSdkMcpServer(bundle) - [ ] System prompt explicitly forbids the AI from fabricating files on
FileNotFoundError - [ ] ⭐ Frontend includes
makers-conversation-idheader on/chat; omits the header on/stop(uses body)
See `review-checklist.md` for the cross-route checklist.
---
Frontend Call Example (chat + stop + file upload)
// Frontend code example
const conversationId = getOrCreateConversationId(); // UUID cached in localStorage
// 1. /chat: header required
const chatResp = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'makers-conversation-id': conversationId, // ⭐ required
},
body: JSON.stringify({
message: userInput,
files: uploadedFiles, // [{ name, base64 }]
}),
});
// 2. /stop: ⚠️ NEVER send the header — pass via body
async function stopAgent() {
await fetch('/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // no makers-conversation-id
body: JSON.stringify({ conversation_id: conversationId }),
});
}---
See Also
- Route C (OpenAI Agents): `openai-agents.md`
- Route D (LangGraph + DeepAgents): `langgraph.md`
- Route E (CrewAI): `crewai.md`
- Platform conventions: `node-entry.md`
- Sandbox & tools reference: `sandbox.md`
- Review checklist: `review-checklist.md`
DeepAgents (Node)
Use when: long-running tasks with automatic context compression, sub-agent orchestration, middleware (retry/call-limit).
Core pattern:createDeepAgent({ model, systemPrompt, tools, middleware })+agent.stream({ messages }, { streamMode }).
---
Dependencies
npm install deepagents @langchain/openai @langchain/core zodNote:deepagentsis a platform-provided package bundled with the EdgeOne Makers agent runtime. It is automatically available in the deployed environment. For local development, useedgeone makers devwhich sets up the runtime with all platform packages.
edgeone.json:
{
"agents": {
"framework": "deepagents"
}
}deepagentsand all@langchain/*packages are auto-externalized by the CLI — no manualexternalNodeModulesconfig needed.
---
When to Pick DeepAgents
✅ Good fit:
- Long agent tasks (writing, research) — automatic context compression saves manual work
- Sub-agent orchestration with isolated context
- Multi-step research workflows (search → deep-read → cite → produce)
❌ Not a fit:
- Need fine-grained graph control (nodes, edges, conditional routing) → use LangGraph
- Need a sandbox to run code → Route B (Claude Agent SDK)
- Multi-agent handoff → Route C (OpenAI Agents SDK)
---
Core Pattern
1. Model initialization
import { ChatOpenAI } from '@langchain/openai';
const MODEL_NAME = '@makers/deepseek-v4-flash';
let _model: ChatOpenAI | null = null;
function getModel(env: Record<string, string>): ChatOpenAI {
if (_model) return _model;
_model = new ChatOpenAI({
model: MODEL_NAME,
apiKey: env.AI_GATEWAY_API_KEY,
configuration: { baseURL: env.AI_GATEWAY_BASE_URL },
temperature: 0,
timeout: 300_000,
});
return _model;
}2. Agent assembly with middleware
import { createDeepAgent } from 'deepagents';
let _agent: any = null;
function getAgent(model: any) {
if (_agent) return _agent;
_agent = createDeepAgent({
model,
systemPrompt: 'You are a helpful research assistant.',
tools: [internetSearch],
maxTurns: 30,
});
return _agent;
}3. Sub-agent orchestration
import { createDeepAgent } from 'deepagents';
const researchAgent = createDeepAgent({
model,
systemPrompt: 'You are a research expert.',
tools: [internetSearch, fetchWebpage],
});
const writerAgent = createDeepAgent({
model,
systemPrompt: 'You are a writer.',
tools: [],
subAgents: [
{
name: 'research_specialist',
description: 'Use this for in-depth research tasks',
agent: researchAgent,
},
],
});Sub-agent state is automatically isolated — the parent only sees the final result.
4. Streaming SSE
async function* eventStream(agent: any, message: string, conversationId: string, signal?: AbortSignal) {
try {
const stream = await agent.stream(
{ messages: [{ role: 'user', content: message }] },
{ streamMode: 'messages', signal, configurable: { thread_id: conversationId } },
);
for await (const chunk of stream) {
if (signal?.aborted) break;
const [msg] = chunk;
if (msg.tool_call_chunks?.length) {
for (const tc of msg.tool_call_chunks) {
if (tc.name) yield sseEvent({ type: 'tool_call', name: tc.name });
}
} else if (msg.type === 'tool') {
yield sseEvent({ type: 'tool_result', name: msg.name, content: msg.text?.slice(0, 500) ?? '' });
} else if (msg.text) {
yield sseEvent({ type: 'ai_response', content: msg.text });
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError' && !signal?.aborted) {
yield sseEvent({ type: 'error_message', content: (e as Error).message });
}
}
yield 'data: [DONE]\n\n';
}5. onRequest entry
export async function onRequest(context: any) {
const { request, env, conversation_id: conversationId, store } = context;
const { message } = request?.body ?? {};
if (!message) return new Response('Missing message', { status: 400 });
const signal = request?.signal as AbortSignal | undefined;
const model = await getModel(env);
const agent = getAgent(model);
return createSSEResponse((sig) => eventStream(agent, message, conversationId, sig), signal);
}---
Memory
DeepAgents reuses LangGraph's memory adapters:
const checkpointer = context.store.langgraphCheckpointer; // direct property
const lgStore = context.store.langgraphStore; // direct property---
Review Checklist
- [ ]
edgeone.jsonhasagents.framework: "deepagents" - [ ] Model/agent instances cached as module-level singletons
- [ ] env from
context.env— neverprocess.env - [ ]
maxTurnsis set to cap agent loops - [ ] Streaming uses
streamMode: 'messages' - [ ] Signal forwarded and checked inside the loop
- [ ] Stream ends with
data: [DONE]\n\n
LangGraph (Node)
Use when: fine-grained graph orchestration, custom node/edge control, human-in-the-loop (interrupt/resume), persistent thread state.
Core pattern:StateGraph+compile({ checkpointer, store })+graph.stream()→ SSE.
---
Dependencies
npm install @langchain/langgraph @langchain/openai @langchain/core zodedgeone.json:
{
"agents": {
"framework": "langgraph"
}
}All@langchain/*packages are auto-externalized by the CLI — no manualexternalNodeModulesconfig needed.
---
When to Pick LangGraph
✅ Good fit:
- Need fine-grained control over execution flow (nodes, edges, conditional routing)
- Human-in-the-loop workflows (
interrupt/resume) - Need persistent thread state (
langgraphCheckpointer) + long-term KV (langgraphStore) - Complex multi-node pipelines with subgraphs
❌ Not a fit:
- Simple single-agent tasks → use DeepAgents (higher-level, less boilerplate)
- Need a sandbox to run code → Route B (Claude Agent SDK)
- Multi-agent handoff → Route C (OpenAI Agents SDK)
---
Core Pattern
1. Model initialization
import { ChatOpenAI } from '@langchain/openai';
const MODEL_NAME = '@makers/deepseek-v4-flash';
let _model: ChatOpenAI | null = null;
function getModel(env: Record<string, string>): ChatOpenAI {
if (_model) return _model;
_model = new ChatOpenAI({
model: MODEL_NAME,
apiKey: env.AI_GATEWAY_API_KEY,
configuration: { baseURL: env.AI_GATEWAY_BASE_URL },
temperature: 0,
timeout: 300_000,
});
return _model;
}2. Graph construction
import { StateGraph, MessagesAnnotation, START, END } from '@langchain/langgraph';
import { ToolNode } from '@langchain/langgraph/prebuilt';
function buildGraph(model: any, tools: any[], checkpointer: any, store: any) {
const modelWithTools = model.bindTools(tools);
const toolNode = new ToolNode(tools);
async function agentNode(state: typeof MessagesAnnotation.State) {
return { messages: [await modelWithTools.invoke(state.messages)] };
}
function shouldContinue(state: typeof MessagesAnnotation.State): 'tools' | '__end__' {
const last = state.messages[state.messages.length - 1] as any;
return (last.tool_calls?.length) ? 'tools' : '__end__';
}
return new StateGraph(MessagesAnnotation)
.addNode('agent', agentNode)
.addNode('tools', toolNode)
.addEdge(START, 'agent')
.addConditionalEdges('agent', shouldContinue)
.addEdge('tools', 'agent')
.compile({
checkpointer, // ⭐ context.store.langgraphCheckpointer
store, // ⭐ context.store.langgraphStore
});
}3. Streaming SSE
async function* eventStream(graph: any, message: string, conversationId: string, signal?: AbortSignal) {
try {
const stream = await graph.stream(
{ messages: [{ role: 'user', content: message }] },
{ streamMode: 'messages', signal, configurable: { thread_id: conversationId } },
);
for await (const chunk of stream) {
if (signal?.aborted) break;
const [msg] = chunk;
if (msg.tool_call_chunks?.length) {
for (const tc of msg.tool_call_chunks) {
if (tc.name) yield sseEvent({ type: 'tool_call', name: tc.name });
}
} else if (msg.type === 'tool') {
yield sseEvent({ type: 'tool_result', name: msg.name, content: msg.text?.slice(0, 500) ?? '' });
} else if (msg.text) {
yield sseEvent({ type: 'ai_response', content: msg.text });
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError' && !signal?.aborted) {
yield sseEvent({ type: 'error_message', content: (e as Error).message });
}
}
yield 'data: [DONE]\n\n';
}4. onRequest entry
export async function onRequest(context: any) {
const { request, env, conversation_id: conversationId, store } = context;
const { message } = request?.body ?? {};
if (!message) return new Response('Missing message', { status: 400 });
const signal = request?.signal as AbortSignal | undefined;
const model = await getModel(env);
// ⭐ Must use toLangChainTools to get real StructuredTool instances
const { tool } = await import('@langchain/core/tools');
const tools = context.tools.toLangChainTools(tool);
// ⭐ LangGraph adapters (direct properties)
const checkpointer = store.langgraphCheckpointer;
const lgStore = store.langgraphStore;
const graph = buildGraph(model, tools, checkpointer, lgStore);
return createSSEResponse((sig) => eventStream(graph, message, conversationId, sig), signal);
}---
Memory
// Direct properties on context.store (not methods)
const checkpointer = context.store.langgraphCheckpointer; // short-term thread state
const lgStore = context.store.langgraphStore; // long-term KV
// Use conversation_id as thread_id
await graph.invoke(input, { configurable: { thread_id: context.conversation_id } });⚠️langgraphStore.searchdoes NOT perform vector retrieval —scoreis alwaysundefined.
---
Stream Modes
'messages': token-level stream (most common, ideal for SSE)'updates': node-level stream (one emission per node completion)'values': full state at every step (useful for debugging)
---
Human-in-the-Loop
LangGraph supports interrupt / resume for human-in-the-loop flows. When interrupt() is called inside a node, the graph pauses and raises GraphInterrupt. The runtime handles this gracefully (not treated as an error).
---
Review Checklist
- [ ]
edgeone.jsonhasagents.framework: "langgraph" - [ ] env from
context.env— neverprocess.env - [ ]
context.store.langgraphCheckpointer+context.store.langgraphStoreused (direct properties) - [ ]
thread_id=context.conversation_idinconfigurable - [ ] Signal forwarded and checked inside the loop
- [ ] Stream ends with
data: [DONE]\n\n - [ ] Model instance cached as module-level singleton; graph compiled per request with
context.storecheckpointer
Route C: OpenAI Agents SDK (@openai/agents)
Use when: multi-agent collaboration (handoff),guardrails, or scenarios that needSessionto auto-prepend history.
Core pattern:Agent+run()streaming +context.store.openaiSession()+ event-to-SSE mapping.
---
Dependencies
npm install @openai/agents openai zodedgeone.json:
{
"agents": {
"framework": "openai-agents-sdk"
}
}If you encounter build errors likeDynamic requireorCannot find module, add"externalNodeModules": ["openai", "@openai/agents"]to theagentsconfig. Unlikedeepagents/@langchain/*/claude-agent-sdk, these are not auto-externalized.
---
When to use Route C
✅ Good fit:
- Multi-agent collaboration (a Triage Agent routing to specialist Agents via
handoff) - Need Session to auto-prepend history (don't want to maintain a messages array by hand)
- Want OpenAI Agents'
guardrailsmechanism for safety rails - Connect to EdgeOne AI Gateway via the OpenAI-compatible protocol
❌ Not a fit:
- A single agent with simple text generation → DeepAgents is simpler
- Need a sandbox to run Python / handle uploaded files → Route B (Claude Agent SDK) is more suitable
- Want fine-grained graph orchestration like LangGraph → Route D (LangGraph)
---
Core pattern breakdown
1. Model initialization (OpenAI-compatible → AI Gateway)
import OpenAI from 'openai';
import { OpenAIChatCompletionsModel } from '@openai/agents';
const DEFAULT_MODEL = '@makers/deepseek-v4-flash';
function buildModel(env: Record<string, string | undefined>) {
const llmClient = new OpenAI({
apiKey: env.AI_GATEWAY_API_KEY,
baseURL: env.AI_GATEWAY_BASE_URL,
});
return new OpenAIChatCompletionsModel(
llmClient,
env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
);
}⭐ Note:new OpenAI({ apiKey, baseURL })reads fromcontext.env.X(NOTprocess.env).envis passed in by the caller fromcontext.env.
2. Agent and tool definitions
import { Agent } from '@openai/agents';
// ⭐ context.tools.all() returns OpenAI Agents-compatible function tools directly
const agent = new Agent({
name: 'Assistant',
instructions: 'You are a helpful assistant. Use the available tools to answer questions.',
tools: context.tools.all(),
model,
});Whenagents.framework: "openai-agents-sdk"is set inedgeone.json,context.tools.all()returns tools already in OpenAI function tool format — no manual wrapping needed.
3. Session persistence (key: use the openaiSession adapter)
import type { Session } from '@openai/agents';
// Inside an agent endpoint: context.store is directly available
const session: Session | undefined =
context.store && context.conversation_id
? context.store.openaiSession(context.conversation_id)
: undefined;
// Pass it to run(); the framework auto-prepends history
const result = await run(agent, message, { stream: true, signal, session });⭐ Do NOT manually concatenate a messages array. Pass the Session object returned byopenaiSession()torun(), and the framework automatically pulls history from the store and appends this turn's exchange.
4. Stream event → SSE protocol mapping (the most critical conversion)
// Convert SDK stream events into this project's SSE events
function toSseEvent(e: any) {
// Streaming text delta from the model
if (e.type === 'raw_model_stream_event' && e.data?.type === 'output_text_delta') {
return { event: 'ai_response', data: { content: e.data.delta as string } };
}
// Tool call started
if (e.type === 'run_item_stream_event' && e.name === 'tool_called') {
const toolName = e.item?.name ?? e.item?.rawItem?.name;
if (toolName) return { event: 'tool_call', data: { name: toolName } };
}
// Tool returned
if (e.type === 'run_item_stream_event' && e.name === 'tool_output') {
const name = e.item?.name ?? e.item?.rawItem?.name;
const out = e.item?.output ?? e.item?.rawItem?.output;
return { event: 'tool_result', data: { name, content: typeof out === 'string' ? out.slice(0, 500) : JSON.stringify(out).slice(0, 500) } };
}
// Handoff (multi-agent switch)
if (e.type === 'agent_updated_stream_event') {
return { event: 'tool_call', data: { name: `handoff:${e.agent?.name}` } };
}
return null; // ignore other events
}5. onRequest main entry assembly
import { run, Agent, OpenAIChatCompletionsModel, type Session } from '@openai/agents';
import OpenAI from 'openai';
import { createLogger, sseEvent, createSSEResponse } from '../_shared';
import { createTools } from './_tools';
const logger = createLogger('chat');
const DEFAULT_MODEL = '@makers/deepseek-v4-flash';
export async function onRequest(context: any) {
const message = (context.request.body ?? {}).message as string | undefined;
if (!message) {
return new Response(JSON.stringify({ error: "'message' is required" }), {
status: 400, headers: { 'Content-Type': 'application/json' },
});
}
const signal = context.request.signal as AbortSignal | undefined;
// ⭐ env always comes from context.env; never use process.env
const env = (context.env ?? {}) as Record<string, string | undefined>;
// OpenAI-compatible client → AI Gateway
const llmClient = new OpenAI({
apiKey: env.AI_GATEWAY_API_KEY,
baseURL: env.AI_GATEWAY_BASE_URL,
});
const model = new OpenAIChatCompletionsModel(
llmClient,
env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
);
const agent = new Agent({
name: 'Assistant',
instructions: 'You are a helpful assistant.',
tools: context.tools.all(),
model,
});
// Session: use the store adapter directly; do not splice history by hand
const session: Session | undefined =
context.store && context.conversation_id
? context.store.openaiSession(context.conversation_id)
: undefined;
return createSSEResponse(
async function* () {
try {
const result = await run(agent, message, { stream: true, signal, session });
for await (const event of result.toStream()) {
if (signal?.aborted) break;
const sse = toSseEvent(event);
if (sse) yield sseEvent({ type: sse.event, ...sse.data });
}
} catch (e) {
const err = e as Error;
if (err.name === 'AbortError' || signal?.aborted) return;
if (err.message?.includes('terminated') && signal?.aborted) return;
yield sseEvent({ type: 'error_message', content: err.message });
}
},
signal,
);
}6. /stop endpoint (interrupt the current run)
// agents/stop/index.ts
export async function onRequest(context: any) {
// ⚠️ Read the body only; never read the makers-conversation-id header
// (it would sticky-route to the chat instance currently running)
const conversationId = context.request?.body?.conversation_id as string | undefined;
if (!conversationId) {
return new Response('Missing conversation_id', { status: 400 });
}
const ret = context.utils.abortActiveRun(conversationId);
return new Response(JSON.stringify({
status: ret?.aborted ? 'aborting' : 'idle',
conversation_id: conversationId,
...ret,
}), {
status: 200,
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
});
}7. /history endpoint (cloud-function, no AI calls)
// cloud-functions/history/index.ts
import { createLogger } from '../_logger';
const logger = createLogger('history');
export async function onRequestPost(context: any) {
const body = await readJsonBody(context);
// cloud-function: use body or context.agent.conversation_id
const conversationId =
(body.conversation_id || body.conversationId)
|| '';
const store = context.agent?.store; // ⭐ cloud-function uses context.agent.store
if (!store || !conversationId) {
return Response.json({ conversation_id: conversationId, messages: [] });
}
// ⭐ Single-object input! Not (id, options)
const history = await store.getMessages({
conversationId,
limit: 100,
order: 'asc',
});
// Filter out SDK-internal messages, group by run_id, take one user+assistant pair per turn
// ... (see the openai-agents-test template for the full implementation)
return Response.json({ conversation_id: conversationId, messages: history });
}
async function readJsonBody(context: any) {
try {
return await context.request.json(); // cloud-function needs await here, unlike agent runtime
} catch { return {}; }
}⚠️ Inside a cloud-function,context.request.bodybehaves the same as in the agent runtime (already-parsed object), but some older templates/routes also expose an asynccontext.request.json()as a fallback. Prefercontext.request.body.
---
Route C review checklist
- [ ]
edgeone.jsonsetsagents.framework: "openai-agents-sdk"(required if you inject tools viacontext.tools.all()) - [ ] Model initialization uses
context.env.AI_GATEWAY_API_KEY/AI_GATEWAY_BASE_URL— never reads `process.env` - [ ] Session uses
context.store.openaiSession(conversation_id); no hand-spliced messages array - [ ] Stream-to-SSE mapping:
output_text_delta→ai_response,tool_called→tool_call,tool_output→tool_result - [ ] AbortSignal is passed through to
run(), and the for-await loop checkssignal?.aborted - [ ] Error classification: silence
AbortError/ "terminated"; emit everything else aserror_message - [ ]
/stopuses only the body{ conversation_id }; does not send themakers-conversation-idheader - [ ]
/historyusescontext.agent.store.getMessages({ conversationId, limit })(single-object input) - [ ]
context.request.headers['x-foo']uses index access — not.get('x-foo') - [ ] ⭐ The frontend includes the
makers-conversation-idheader when calling/chat; omits it when calling/stop(uses the body instead)
See also: the platform and capabilities docs.
---
Frontend call examples
// Frontend API helper
const KEY = 'eo_conversation_id';
function getOrCreateConversationId(): string {
const cached = localStorage.getItem(KEY);
if (cached) return cached;
const fresh = crypto.randomUUID();
localStorage.setItem(KEY, fresh);
return fresh;
}
// /chat: header is required
export async function callChat(message: string) {
const conversationId = getOrCreateConversationId();
return fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'makers-conversation-id': conversationId, // ⭐ required
},
body: JSON.stringify({ message }),
});
}
// /stop: ⚠️ NEVER send the header
export async function stopAgent() {
const conversationId = getOrCreateConversationId();
return fetch('/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // no makers-conversation-id
body: JSON.stringify({ conversation_id: conversationId }),
});
}
// /history: cloud-function — header or body is fine, either one works
export async function fetchHistory() {
const conversationId = getOrCreateConversationId();
const resp = await fetch('/history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'makers-conversation-id': conversationId, // recommended, mirrors /chat
},
body: JSON.stringify({ conversation_id: conversationId }),
});
return resp.json();
}---
Quick diff vs. other frameworks
| Dimension | DeepAgents | Claude SDK | OpenAI Agents |
|---|---|---|---|
| Agent abstraction | createDeepAgent() | query() built-in loop | Agent + run() |
| History persistence | LangGraph checkpointer | claudeSessionStore() | `openaiSession(convId)` auto-prepend |
| Tool entry point | context.tools.all() | toClaudeMcpServer() | new Agent({ tools }) or context.tools.all() |
| Multi-agent | Sub-agent orchestration | ❌ (single agent) | ⭐ Handoff |
| Guardrails | N/A | Use permissionMode | ⭐ Built-in input_guardrails |
See also: langgraph.md, deepagents.md, crewai.md.
agents/ vs cloud-functions/ Convention
Covers: separation of AI inference (agents/) from data CRUD (cloud-functions/), layout, storage dependencies, store entry point differences.
---
cloud-functions Convention (Data Persistence)
Principle
- Separate from
agents/:agents/handles AI,cloud-functions/handles data CRUD - One directory per resource:
cloud-functions/<resource>/index.ts - Returns JSON (no streaming); used for KV / Blob / preferences / history
Example layout
cloud-functions/
├── _logger.ts
├── articles/index.ts → article CRUD
├── preferences/index.ts → user preference read/write
└── history/index.ts → conversation history retrievalStorage
- Access conversation-scoped storage via
context.agent.store
Health Check
The runtime has a built-in `/health` endpoint that returns {"status": "ok", "route_count": N} — no need to create one manually for basic process liveness checks.
If you need a custom health check (e.g., checking session state, database connectivity, or active run status), write it as an agents/ endpoint (not cloud-functions) so it has access to context.store and context.utils:
// agents/health.ts — custom health check with session state
export async function onRequest(context: any) {
const activeRuns = /* check active runs */;
return new Response(JSON.stringify({
status: 'ok',
activeRuns,
uptime: process.uptime(),
}), { headers: { 'Content-Type': 'application/json' } });
}---
Conversation ID + Frontend Convention
Covers: makers-conversation-id dual-channel contract, /stop inverted rule, frontend call patterns, endpoint cheat sheet.
---
Frontend Convention
Principle
- The frontend framework is not prescribed — use Next.js, Vite, React, Vue, plain HTML, or any framework
- Frontend calls agent endpoints via
fetch('/<action>', { method:'POST', body }), then reads SSE withEventSource/ReadableStream
⭐ Conversation ID and the makers-conversation-id Header (Iron Rule)
Every fetch to an AI endpoint must carry the `makers-conversation-id` HTTP header — that means /chat, /outline, /create, /create-lite, every endpoint under agents/. Otherwise:
- The backend's
context.conversation_idwill be empty - The session adapters (
openaiSession/claudeSessionStore) cannot resume history - Sticky routing breaks — each request may land on a different agent instance
/stopcannot find the running run, and abort silently fails
Generate + persist pattern (recommended on the frontend):
// Frontend conversation ID helper
const KEY = 'eo_conversation_id';
export function getOrCreateConversationId(): string {
if (typeof window === 'undefined') return '';
const cached = localStorage.getItem(KEY);
if (cached) return cached;
const fresh = crypto.randomUUID();
localStorage.setItem(KEY, fresh);
return fresh;
}
export function rotateConversationId(): string {
const fresh = crypto.randomUUID();
if (typeof window !== 'undefined') localStorage.setItem(KEY, fresh);
return fresh;
}Calling AI endpoints (header is mandatory):
// Frontend code example
const conversationId = getOrCreateConversationId();
const resp = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'makers-conversation-id': conversationId, // ⭐ required
},
body: JSON.stringify({ message, files }),
});Calling `/stop` (⚠️ inverted: never carry the header):
// Note: fetch /stop must NOT carry makers-conversation-id.
// Otherwise sticky routing pins to the same stuck chat instance and abortActiveRun cannot reach the runner.
const resp = await fetch('/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // no makers-conversation-id
body: JSON.stringify({ conversation_id: conversationId }), // pass via body
});Calling `/history` (cloud-function — header is optional):
// /history is a cloud-function: there's no sticky-routing concern; either header or body works.
const resp = await fetch('/history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'makers-conversation-id': conversationId, // recommended (consistent with chat)
},
body: JSON.stringify({ conversation_id: conversationId }),
});Endpoint → Frontend Call Style Cheat Sheet
| Endpoint | Type | Header makers-conversation-id | Body conversation_id |
|---|---|---|---|
/chat | agent | ✅ required | usually not needed |
/outline / /create and other AI endpoints | agent | ✅ required | usually not needed |
/stop | agent | ❌ never | ✅ required (only channel) |
/history | cloud-function | recommended | recommended (either works) |
/preferences and other pure data CRUD | cloud-function | recommended | as needed |
/health and other endpoints with no conversation | cloud-function | not needed | not needed |
i18n
- Use
lib/i18n.tsxto provide a Provider + hook - Language hint: the frontend appends a locale tag (e.g. a Chinese-language tag, or
[Language: English]) to the end of the message; the backend determines locale from this
Environment Variables and Model Convention
Covers: AI_GATEWAY_* variables, WSA_API_KEY for web_search, model initialization patterns.
---
3. Environment Variables and Model Convention
Principle
- Unified gateway variables:
AI_GATEWAY_API_KEY,AI_GATEWAY_BASE_URL, plus optionalAI_GATEWAY_MODEL/AI_GATEWAY_SMALL_MODEL - Missing variables must throw explicitly — never silently degrade
- Default model as a constant:
@makers/deepseek-v4-flash - ⭐ If the template uses `context.tools.web_search`: you must also configure
WSA_API_KEYin the project's environment variables. Create an API KEY in the Tencent Cloud Web Search API console, copy the value, and setWSA_API_KEY=<value>in the EdgeOne project environment variables (reference docs: https://cloud.tencent.com/document/product/1806/130615). This variable is read directly by the sandbox runner; template code typically does not need to reference it explicitly. Without it, search will fail authentication / return 401. Detailed steps incapabilities/tools.md.
LangGraph / DeepAgents — env validation + model initialization (agents/_model.ts)
import { ChatOpenAI } from '@langchain/openai';
const MODEL_NAME = '@makers/deepseek-v4-flash';
export interface AgentEnv {
AI_GATEWAY_API_KEY: string;
AI_GATEWAY_BASE_URL: string;
}
export function getAgentEnv(contextEnv: Record<string, string | undefined> | undefined): AgentEnv {
const source = contextEnv ?? {};
const required = ['AI_GATEWAY_API_KEY', 'AI_GATEWAY_BASE_URL'] as const;
const missing = required.filter((k) => !source[k]?.trim());
if (missing.length) throw new Error(`Missing environment variables: ${missing.join(', ')}`);
return {
AI_GATEWAY_API_KEY: source.AI_GATEWAY_API_KEY!,
AI_GATEWAY_BASE_URL: source.AI_GATEWAY_BASE_URL!,
};
}
// Cache the model instance per baseURL
const modelCache = new Map<string, ChatOpenAI>();
export function createModel(env: AgentEnv, options?: { timeout?: number }): ChatOpenAI {
const cacheKey = `${MODEL_NAME}:${env.AI_GATEWAY_BASE_URL}`;
if (modelCache.has(cacheKey)) return modelCache.get(cacheKey)!;
const model = new ChatOpenAI({
model: MODEL_NAME,
apiKey: env.AI_GATEWAY_API_KEY,
configuration: { baseURL: env.AI_GATEWAY_BASE_URL },
timeout: options?.timeout ?? 300_000,
});
modelCache.set(cacheKey, model);
return model;
}Route B — Gateway env mapping (agents/_model.ts)
const DEFAULT_MODEL = '@makers/deepseek-v4-flash';
export function resolveModelName(env: Record<string, string | undefined>): string {
return env.AI_GATEWAY_MODEL || DEFAULT_MODEL;
}
// Map EdgeOne Gateway variables to the ANTHROPIC_* names the Claude Agent SDK expects.
// Returns a Record to inject into query()'s options.env — never reads process.env.
export function collectGatewayEnv(env: Record<string, string | undefined>): Record<string, string> {
const result: Record<string, string> = {};
if (env.AI_GATEWAY_BASE_URL) result.ANTHROPIC_BASE_URL = env.AI_GATEWAY_BASE_URL;
if (env.AI_GATEWAY_API_KEY) result.ANTHROPIC_API_KEY = env.AI_GATEWAY_API_KEY;
if (env.AI_GATEWAY_SMALL_MODEL || env.AI_GATEWAY_MODEL) {
result.ANTHROPIC_SMALL_FAST_MODEL = env.AI_GATEWAY_SMALL_MODEL || env.AI_GATEWAY_MODEL || '';
}
if (env.ANTHROPIC_CUSTOM_HEADERS) result.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS;
return result;
}
// Caller side (agents/chat/index.ts):
// const gatewayEnv = collectGatewayEnv(context.env); // ⭐ context.env, never process.env
// query({ ..., options: { env: gatewayEnv, ... } })---
File Routing + onRequest Entry Convention
Covers: file-based routing rules, onRequest signature, context fields, environment variable iron rule.---
1. File Routing Convention
Principle
agents/<name>.tsoragents/<name>/index.ts→ automatically mapped toPOST /<name>- Files with an
_prefix are not mapped to routes; they are internal modules only (e.g._shared.ts/_tools.ts/_skills.ts) - ⭐ The CLI scans and generates `.edgeone/agent-node/config.json` automatically at build time. Templates do not need to hand-write it.
- For method-specific handlers, export
onRequestPost/onRequestGet/onRequestPut/onRequestPatch/onRequestDelete/onRequestHead/onRequestOptions. Dispatch order: method-specific first, then fall back toonRequest.
Example
agents/create-lite.ts → POST /create-lite
agents/create.ts → POST /create
agents/outline.ts → POST /outline
agents/stop.ts → POST /stop
agents/chat/index.ts → POST /chat
agents/_shared.ts → internal module (not a route)
agents/_model.ts → internal module (not a route).edgeone/agent-node/config.json (auto-generated artifact — do not hand-edit)
This file appears after a build; routes are derived by the CLI scanning the agents/ directory. Do not put it in any "must-maintain" checklist that you check into version control — it is a build artifact.
⚠️ How to spot a violation during review: check the project root .gitignore:- Contains.edgeone→ a local.edgeone/agent-node/config.jsonis just a build artifact, not a violation
- Does not contain.edgeone→ the entire.edgeone/directory has been committed into the repo, that is the violation (either add it to.gitignore, or rungit rm -r --cached .edgeoneto clean up the commit)
>
Older skill versions required hand-maintaining this file — that is deprecated. To add a new endpoint, just drop the corresponding file into agents/.---
2. onRequest Entry Convention
Principle
- The default exported function is named
onRequest, signature(context: any) => Promise<Response> - Method-specific variants are also supported:
onRequestPost/onRequestGet/onRequestPut/onRequestPatch/onRequestDelete/onRequestHead/onRequestOptions(works for both agent and cloud-function endpoints) - Dispatch order: method-specific match first, fall back to
onRequest - Destructure platform-injected resources from
context. Do not import the model SDK yourself, and do not readprocess.env(usecontext.env) - The request body has already been parsed into an object by the platform; just use
context.request.bodydirectly - ⚠️ Request headers are a plain object, not the Headers API: use
context.request.headers['x-foo'], not.get('x-foo')
Fields injected on context
| Field | Type | Description |
|---|---|---|
context.request.body | object | The parsed request body |
context.request.signal | AbortSignal | Client-disconnect signal — you must listen for this |
context.request.headers | Record<string, string> | ⚠️ Plain object — use `headers['x']`, not .get('x') |
context.request.method | string | HTTP method |
context.request.url | string | Full URL |
context.request.query | object | Parsed query params (aligned with Node Functions) |
context.env | Record<string,string> | ⭐ Injected environment variables (AI_GATEWAY_* etc.). `process.env` is forbidden inside `agents/` and `cloud-functions/` — always use context.env |
context.tools | ToolsContext | Platform tool set (lazy-loaded; shape is determined by agents.framework) |
context.sandbox | `SandboxClient \ | null` |
context.store | AgentMemory | Conversation store (messages + metadata + adapters for the five frameworks) |
context.conversation_id | string | Automatically injected from the HTTP header makers-conversation-id |
context.run_id | string | The current run ID (note: it is run_id, not runId in camelCase) |
context.utils.abortActiveRun(conversationId) | function | Injected by the agent runtime only — cloud-function does not have it |
context.agent | object | Injected only in cloud-function, contains { conversation_id, store }. ⚠️ The shape of store is not the same as context.store — the runtime strips out langgraphCheckpointer and langgraphStore inside createCloudFunctionAgentStore, leaving only the generic message API plus openaiSession / claudeSessionStore. See store.md |
Skeleton
export async function onRequest(context: any) {
const { request, env, tools: contextTools, sandbox, store } = context;
const body = request?.body ?? {};
const signal = request?.signal as AbortSignal | undefined;
const conversationId = context.conversation_id;
// 1. Validate input
if (!body.message) {
return new Response(JSON.stringify({ error: "'message' is required" }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 2. Prepare the model (always read env via context.env, never process.env)
// 3. Return the SSE stream
}⚠️ The Iron Rule on Environment Variables
- Every
.tsfile underagents/: `process.env.X` is forbidden, `context.env.X` is mandatory - Every
.tsfile undercloud-functions/: `process.env.X` is forbidden, `context.env.X` is mandatory - ⚠️ Both reads and writes are forbidden: mutations like
process.env.X = 'foo'are equally illegal —process.envis shared across the same process, multiple handler instances may run concurrently inside the agent runtime, and a mutation will pollute other handlers' env. If some SDK requires "configure via environment variable" (e.g.OPENAI_AGENTS_DISABLE_TRACING), prefer the SDK's own options/parameter API. If the SDK truly only supports the env path, accept the pollution but add a comment explaining it, and concentrate it inside a single init file. - Frontend directory (
app/orsrc/): not subject to this restriction (frontend frameworks handle env vars in their own way) - Shared internal modules (
_shared.ts/_model.ts, etc.): takeenvas a parameter, with the caller passing incontext.env. The module itself must not read global env.
---
externalNodeModules (build config, usually not needed)
The CLI uses esbuild to bundle agent code. Some packages cannot be bundled and must remain as separate node_modules at runtime. The CLI auto-externalizes the most common ones:
| Auto-externalized (no config needed) | Reason |
|---|---|
deepagents | Default external |
@anthropic-ai/claude-agent-sdk | Default external |
All @langchain/* packages | Auto-detected from package.json |
| OpenTelemetry packages | Observability layer handles it |
You only need to manually add `externalNodeModules` when a package that is NOT in the list above fails to bundle. Common symptoms:
Dynamic require of "xxx" is not supportedCannot find module 'xxx'at runtime (but it's in node_modules)- Native
.nodeaddon fails to load
Example (only if needed):
{
"agents": {
"framework": "openai-agents-sdk",
"externalNodeModules": ["openai", "@openai/agents"]
}
}Packages that may need manual externalization:
| Package | When to add |
|---|---|
openai | If using OpenAI Agents SDK and build fails |
@openai/agents | Same as above |
sharp | If doing image processing (native addon) |
⚠️ Do NOT install `puppeteer-core` for browser automation — usecontext.sandbox.browserinstead (built-in:goto,screenshot,click,type,evaluate). The platform sandbox already provides a managed Chromium instance.
| bcrypt | Native C++ addon |
Python Agent Runtime Convention
The Python agent runtime is an ASGI application (uvicorn). It shares the same platform conventions as the Node runtime (file-based routing, makers-conversation-id header contract, SSE protocol, etc.), but uses Python idioms.Applies to: Route E (CrewAI) and any future Python-based routes (LangGraph Python, DeepAgents Python, etc.).
---
Prerequisites
edgeone.jsonmust setagents.frameworkto one of:claude-agent-sdk,openai-agents-sdk,langgraph,crewai, ordeepagents- Dependencies go in
requirements.txt(notpackage.json) - Python agent directories require
__init__.pyfiles for relative imports to work
---
1. Entry Signature
# agents/<name>/index.py or agents/<name>.py → POST /<name>
async def handler(ctx):
"""The runtime looks for a top-level `handler` function in each route module."""
...- The
handlerfunction must beasync. - The single parameter (
ctx) is anAgentContextdataclass. - If handler is an async generator (
async def handler(ctx): ... yield ...), the runtime auto-wraps it as a streaming response. - Internal modules use
_prefix:_llm.py,_tools.py,_state.pyetc. (same convention as TS_shared.ts). - Directory-form agents must include `__init__.py` for relative imports to work:
agents/
├── chat/
│ ├── __init__.py # Required (can be empty)
│ ├── index.py # Entry: async def handler(ctx)
│ ├── _llm.py # Internal: from ._llm import get_model
│ └── _tools.py
└── stop.py # Single-file agent (no __init__.py needed)---
2. Context Object (ctx)
| Field | Type | Description |
|---|---|---|
ctx.request.body | dict | Parsed JSON request body |
ctx.request.headers | dict | Request headers (lowercase keys, plain dict) |
ctx.request.signal | asyncio.Event | Cancellation signal — check with ctx.request.signal.is_set() |
ctx.request.query | dict | URL query parameters |
ctx.env | dict | Environment variables — ⚠️ never use `os.environ` |
ctx.conversation_id | str | Injected from makers-conversation-id header |
ctx.run_id | str | Current run ID |
ctx.store | ConversationMemory | Message CRUD + LangGraph adapters |
ctx.tools | Tools | Platform tools (lazy-loaded, shaped by agents.framework) |
ctx.sandbox | Sandbox | Sandbox client (lazy-loaded) |
ctx.kv | KV store | Per-route KV store |
ctx.utils | ContextUtils | SSE helpers + abort utility |
ctx.tracer | Tracer | Manual observability span API |
⚠️ The Iron Rule on Environment Variables
- Every
.pyfile underagents/: `os.environ` is forbidden, `ctx.env` is mandatory - Frontend code (
app/,src/) is not subject to this restriction - Shared internal modules (
_llm.py, etc.): takeenvas a parameter from the caller
---
3. SSE Streaming
Recommended pattern (via ctx.utils):
import time
async def handler(ctx):
message = ctx.request.body.get("message", "")
if not message:
return {"error": "'message' is required"}, 400
async def gen():
# ... LLM streaming logic ...
yield ctx.utils.sse({"type": "ai_response", "content": "Hello"})
yield ctx.utils.sse({"type": "ping", "ts": int(time.time() * 1000)})
yield ctx.utils.sse({"type": "usage", "input_tokens": 10, "output_tokens": 5})
yield b"data: [DONE]\n\n"
return ctx.utils.stream_sse(gen())Alternative (explicit StreamResponse):
from _platform.context import StreamResponse, sse
async def handler(ctx):
async def gen():
yield sse({"type": "ai_response", "content": "World"})
return StreamResponse.sse(gen())Both approaches produce identical SSE responses with correct headers (text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no, Connection: keep-alive).
---
4. Return Values
Python handlers can return:
| Return type | Runtime behavior |
|---|---|
dict / list | JSON response (200) |
str | Plain text response (200) |
(body, status) tuple | Response with custom status code |
StreamResponse | Streaming response (via ctx.utils.stream_sse() or StreamResponse.sse()) |
| async generator | Auto-wrapped as streaming response |
---
5. Memory / Store API
Python uses positional arguments (not a single-object input like Node):
# Append a message
msg_id = await ctx.store.append_message(ctx.conversation_id, "user", "Hello!")
# Get messages (default: ascending order)
messages = await ctx.store.get_messages(ctx.conversation_id, limit=50)
# Convert to model input format
openai_msgs = ctx.store.to_openai_input(messages)
# LangGraph adapters (direct properties, snake_case)
checkpointer = ctx.store.langgraph_checkpointer
lg_store = ctx.store.langgraph_store⚠️ Same constraint as Node:langgraph_checkpointer/langgraph_storeare only available in agent endpoints (agents/<name>/), not cloud-functions.
---
6. Abort / Stop Convention
# agents/stop.py
async def handler(ctx):
# ⚠️ Read conversation_id from body only (no makers-conversation-id header)
target = ctx.request.body.get("conversation_id") or ""
result = ctx.utils.abortActiveRun(target) # camelCase (aligned with Node)
# Alias: ctx.utils.abort_active_run(target)
return {
"status": "aborted" if result.aborted else "idle",
"conversation_id": result.conversation_id,
"run_id": result.run_id,
}---
7. Node ↔ Python Naming Mapping
| Node (TS) | Python |
|---|---|
context.request.signal.aborted | ctx.request.signal.is_set() |
context.store.appendMessage({conversationId, role, content}) | await ctx.store.append_message(conversation_id, role, content) |
context.store.getMessages({conversationId, limit}) | await ctx.store.get_messages(conversation_id, limit=N) |
context.store.langgraphCheckpointer | ctx.store.langgraph_checkpointer |
context.store.langgraphStore | ctx.store.langgraph_store |
context.store.toOpenAIInput(msgs) | ctx.store.to_openai_input(msgs) |
context.utils.abortActiveRun(id) | ctx.utils.abortActiveRun(id) or ctx.utils.abort_active_run(id) |
createSSEResponse(gen, signal) | ctx.utils.stream_sse(gen()) |
sseEvent({type, content}) | ctx.utils.sse({"type": ..., "content": ...}) |
---
8. Blocking Code (Critical for Python)
CrewAI's crew.kickoff() is synchronous and blocking. You MUST offload it to a thread:
import asyncio
async def handler(ctx):
crew = build_crew(...)
# ⚠️ WRONG: result = crew.kickoff() ← blocks event loop, kills heartbeats
# ✅ RIGHT:
result = await asyncio.to_thread(crew.kickoff)This applies to any synchronous SDK call (CrewAI, some LangChain tools, file I/O, etc.).
---
9. File Routing (same as Node)
agents/<name>.pyoragents/<name>/index.py→POST /<name>_-prefixed files are internal modules (not routed)- The CLI auto-scans at build time (do not hand-edit config)
---
See Also
- Node runtime conventions: node-entry.md
- Memory / Store: ../capabilities/store.md
- Sandbox & Tools: ../capabilities/sandbox.md
- CrewAI framework route: ../python-frameworks/crewai.md
- Review checklist (§J Python): ../review-checklist.md
SSE Streaming Protocol Convention
Covers: unified event types, heartbeat, response headers, reusable createSSEResponse helper.---
4. SSE Streaming Protocol Convention (the most important unification)
Principle
- Every agent endpoint returns
text/event-stream, with each event formatted asdata: <JSON>\n\n - The
typefield has a fixed enumeration (see table below); the frontend dispatches by type - 5-second
pingheartbeat; the stream ends withdata: [DONE]\n\n - Four required response headers:
Content-Type+Cache-Control:no-cache+Connection:keep-alive+X-Accel-Buffering:no
Unified Event Type Table
| type | Fields | Meaning |
|---|---|---|
ai_response | content | Streaming text delta from the model |
tool_call | name | A tool invocation has started |
tool_result | name, content | Tool result (truncated to ~500 characters) |
suggest_actions | actions[] | Suggested actions (clickable options) |
file_output | base64, filename, description | Downloadable file output |
usage | input_tokens, output_tokens, total_tokens | Token statistics |
ping | ts | Heartbeat keep-alive |
error_message | content | Error message (must not crash the stream) |
| — | — | Send data: [DONE]\n\n at the end |
Reusable SSE Helper (place in agents/_shared.ts — multimodal version recommended)
export function createLogger(name: string) {
return {
log(...args: unknown[]) { console.log(`[${name}][${new Date().toISOString()}]`, ...args); },
error(...args: unknown[]) { console.error(`[${name}][${new Date().toISOString()}]`, ...args); },
};
}
export function sseEvent(data: Record<string, unknown>): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
export function createSSEResponse(
generator: (signal?: AbortSignal) => AsyncGenerator<string>,
signal?: AbortSignal,
): Response {
const encoder = new TextEncoder();
const readableStream = new ReadableStream({
async start(controller) {
const heartbeat = setInterval(() => {
try { controller.enqueue(encoder.encode(sseEvent({ type: 'ping', ts: Date.now() }))); }
catch { /* stream closed */ }
}, 5_000);
try {
for await (const chunk of generator(signal)) {
if (signal?.aborted) break;
controller.enqueue(encoder.encode(chunk));
}
} catch (e) {
const error = e as Error;
if (error.message?.includes('terminated') && signal?.aborted) {
// graceful — aborted with content already sent
} else if (error.name !== 'AbortError' && !signal?.aborted) {
controller.enqueue(encoder.encode(sseEvent({ type: 'error_message', content: error.message })));
}
} finally {
clearInterval(heartbeat);
controller.close();
}
},
cancel() { /* client disconnected */ },
});
return new Response(readableStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}Recommendation: consolidate this helper set into_shared.tsand have every endpoint callcreateSSEResponse(gen, signal).
Don't rewrite a ReadableStream in every file (the older content-creator code did this inline; align toward the multimodal version).---