Skip to main content

Build agents with CrewAI

CrewAI is an open-source Python framework where you describe agents by role ("Research Buddy"), give them tasks, and group them into a crew that works through the tasks. The Agents service runs unmodified CrewAI crews. This page is the complete guide: the author contract, both ways to start (from source or from a Console template), configuration, the deploy, invoking and streaming, sessions, memory, tools from MCP Servers, SDK snippets, common patterns, the debugging checklist, and limits.

Before you begin

  • You can deploy: account, CLI, and sign-in per deploy an agent.
  • Python knowledge. You do not need CrewAI installed locally to deploy — the build happens on the platform.
  • Your project has a model API key saved (console Project Settings, or PUT /v1/projects/{id}/inference). Deploying an agent that calls a model is refused without one.

What your crew.py must define

Your directory must contain a crew.py that defines a variable named crew at module level — at the top level of the file, not inside a function. It can be either of two things: a crewai.Crew object, or a function that takes no arguments and returns one. The second form is a factory, and it is useful when you want a fresh crew for each request.

CrewAI adds one rule of its own: a task description must reference {message}. That placeholder is how the user's incoming message reaches your crew, and the harness fills it in on every turn. Reference {history} as well, and the harness hands your crew the earlier turns of the conversation too:

  • With {history} in a task description: the harness replays earlier turns into it, and {message} carries only the current message. This is the clear, recommended form.
  • Without {history}: the harness folds the prior turns into {message} instead, so turn 2 still sees turn 1 — multi-turn chat works either way.

If the module-level crew is missing, your agent crash-loops — it starts, fails, restarts, and fails again — and the harness startup error appears in the agent's message field. One other startup failure is worth knowing about, and it catches almost everybody the first time they add a tool. See write your own tools below.

An optional requirements.txt is installed at build time, checked against the versions the base image already pins. A conflicting pin fails the build, with a readable error in the agent's message field, rather than crash-looping the agent later.

What the base image already provides

The CrewAI base image ships crewai, crewai-tools, and the first-party crusoe_crewai package (plus its crusoe_core support library), on Python 3.12. Most crews need no extra dependencies.

Build from source, or start from a template

Build from source. Write crew.py in a folder on your machine. The complete example below is deployable as-is; so are the crewai-minimal and crewai-mcp examples in the platform's examples folder.

Start from a Console template. In the Console, go to Compute → Agents → Deploy agent, pick CrewAI, and choose the write mode. The dialog opens with a working CrewAI starter — a crew with both placeholders already wired — which you edit in the browser and deploy with no local setup.

Either way you end with the same thing: a directory whose crew.py defines a module-level crew, deployed by the same platformctl deploy (or its API/Console equivalent).

Configure the framework: env, secrets, MCP attach

Everything below is set per agent and takes effect on the next revision. Full reference: secrets and environment variables.

Environment variables (readable)

platformctl agents env set my-crew-agent CHAT_MODEL=nvidia/NVIDIA-Nemotron-3-Super-120B-A12B
VariableEffect
CHAT_MODELThe model crusoe.foundry_model() picks when called with no argument
CAI_EXPOSE_EXTERNAL=truePublishes the agent's public URL — see invoke
TOOL_SANDBOX=falseRuns your own tool code inside the agent instead of in sandboxes — see write your own tools below
MEMORY_SCOPE=sharedOne common memory bank for all callers — see memory

Secrets (write-only)

platformctl secrets set my-crew-agent MODEL_API_KEY=«redacted:sk-…»

MODEL_API_KEY overrides the project's model key for this one agent. Anything else — a downstream API token — follows the same pattern and is read in Python with os.environ["YOUR_KEY"].

For a credential shared across agents, with versions and read auditing, keep it in the project Secrets store and read it at call time — the only credential path available inside a sandboxed tool anyway, since the sandbox starts with an empty platform environment:

import crusoe_crewai as crusoe

api_key = crusoe.secret("weather-api-key") # latest version
pinned = crusoe.secret("weather-api-key", 3) # a pinned version

See use secrets in workloads.

Attach MCP Servers

If your project hosts MCP Servers, one helper turns them into CrewAI tools. The helper connects eagerly at import: an attached server that is unreachable or refuses its token raises at startup, so the agent crash-loops loudly rather than serving a crew that silently lost a tool. With no servers attached, it returns an empty list and the crew still runs:

from crewai import Agent

import crusoe_crewai as crusoe

assistant = Agent(
role="Assistant",
goal="Answer the user's request, preferring attached tools when they fit.",
backstory="An assistant on the Crusoe AI Platform.",
llm=crusoe.foundry_model(),
tools=[crusoe.RunPython()] + crusoe.mcp_tools(),
verbose=False,
)

Every newly published tool on the server reaches the agent on its next deploy — no code change. The crewai-mcp example in the examples folder is this section, runnable. See publish tools and connect agents and clients for the end-to-end walk.

The Crusoe helpers: crusoe_crewai

import crusoe_crewai as crusoe
  • crusoe.foundry_model() — returns a CrewAI-compatible LLM already pointed at the platform's managed inference endpoint. That endpoint is OpenAI-compatible: it speaks the same HTTP protocol as OpenAI's API. With no arguments the helper uses whatever CHAT_MODEL is set to; when nothing is set anywhere, the platform default is zai/GLM-5.2. To pin a model in code, pass its name: crusoe.foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"). You can also bring your own model — with your own credentials — and the harness runs it as-is.
  • crusoe.RunPython() — a tool class that runs Python in the platform's code sandbox: isolated, used once, capped at 20 seconds. Note the parentheses. This is a class, so you create an instance of it.
  • crusoe.SearchMemory() — a tool class that searches the agent's long-term memory bank and returns the top 5 snippets. Create an instance of this one too.
  • crusoe.secret(name) — reads a project Secret at call time, described above.

The platform's own tools run inside the agent process. Tools you write yourself are moved into a sandbox by default, and that puts one hard rule on how you write them. Read the next section before you add a tool.

Write your own tools

By default the platform runs your tool code in a throwaway sandbox instead of inside the agent. A buggy tool, or one steered by prompt injection — an attacker hiding instructions in text your agent reads — therefore cannot read your credentials. This is the TOOL_SANDBOX setting, and it is on unless you turn it off.

To move a tool into a sandbox, the harness has to be able to name it. It sends the tool's module name and function name to the sandbox, which imports that module and calls that function. Only one shape works: an @tool-decorated function defined at module level, meaning at the top level of a file rather than inside something else. Nothing else can be found by name.

The rule

Tool shapeUnder TOOL_SANDBOX=true (default)
@tool-decorated function at module level in crew.pyWorks — the function runs in a sandbox
A BaseTool subclass you wroteStartup fails
Most ready-made tools from crewai-toolsStartup fails — they are BaseTool subclasses too
crusoe.RunPython() and crusoe.SearchMemory()Works — the platform's own tools are exempt by name

Here is the trap. Subclassing BaseTool is CrewAI's own documented way to write a custom tool, and it is the shape of the ready-made tools in crewai-tools. On this platform, an agent using one does not start.

The error you get

The agent crash-loops, and its message field contains:

tool sandboxing is enabled but could not be installed: tool 'fetch_weather' on agent 'Research Buddy' cannot be isolated (it is a BaseTool subclass or a non-addressable callable). Set TOOL_SANDBOX=false to deliberately run tool code in the agent process, or express the tool as an @tool-decorated module-level function.. Set TOOL_SANDBOX=false to deliberately run tool code in the agent process.

The message quotes both the tool's name, fetch_weather, and the agent's role, Research Buddy. It tells you exactly which tool to fix. The doubled period near the end is in the message itself, not a typo here — the advice is printed twice.

The harness checks every tool before it serves a single request. It refuses to start rather than quietly run your tool next to the agent's credentials. Read it in the agent's message field, or with platformctl logs my-crew-agent --history.

Fix 1: write the tool as an @tool function

The preferred fix. Define it at module level in crew.py:

from crewai import Agent, Crew, Process, Task
from crewai.tools import tool

import crusoe_crewai as crusoe


@tool("fetch_weather")
def fetch_weather(city: str) -> str:
"""Return a short weather summary for a city."""
import httpx
r = httpx.get("https://api.example.com/weather", params={"q": city}, timeout=10)
return r.text


research_buddy = Agent(
role="Research Buddy",
goal="Answer questions about the weather.",
backstory="A concise assistant running on the Crusoe AI Platform.",
llm=crusoe.foundry_model(),
tools=[fetch_weather, crusoe.RunPython()],
verbose=False,
)

Three things to keep in mind when you write one:

  • Define it at module level. A function nested inside another function cannot be imported by name in the sandbox.
  • Arguments must be JSON-serializable. They travel across the network to the sandbox as JSON, so keep them to strings, numbers, booleans, lists, and dicts. Anything else fails with arguments to fetch_weather are not JSON-serialisable, so the call cannot cross the sandbox boundary: ....
  • Read no environment variables. The sandbox starts with an empty platform environment: no model key, no store credentials. If your tool needs a credential, fetch it at call time from the secrets manager with crusoe.secret("name").

Fix 2: turn the sandbox off

If you must use a BaseTool subclass or a crewai-tools built-in, opt out of sandboxing. Your tool code then runs inside the agent itself, next to its credentials. Do this only for tools you fully trust:

platformctl secrets set my-crew-agent TOOL_SANDBOX=false

You should see:

set 1 secret(s) for my-crew-agent

That rolls a new revision. On its next start the agent logs:

tool sandbox OFF (TOOL_SANDBOX=false): tool code runs in the agent process

When sandboxing is on and your tools were accepted, the same startup log says which ones were relocated:

tool sandbox ON: fetch_weather run in isolated sandboxes

For the full sandboxing model — timeouts, what a sandbox can reach, and why the empty environment is the security boundary — see tools.

Complete example: crewai-minimal

The platform's canonical CrewAI example, complete and runnable as-is. Two files.

my-crew-agent/crew.py:

"""crewai-minimal - a trivial CrewAI agent for the Crusoe platform.

THE AUTHOR CONTRACT:
* Expose a module-level ``crew`` (a crewai.Crew or a zero-arg factory
returning one).
* A task description MUST reference ``{message}`` (that is how the user's
input reaches the model). Reference ``{history}`` too so multi-turn
resume is explicit - the harness replays prior turns from the canonical
event log into ``{history}``. (If a crew omits ``{history}``, the
adapter folds prior turns into ``{message}`` instead, so turn 2 still
sees turn 1 - but referencing it here is the clear form.)
"""
from crewai import Agent, Crew, Process, Task

import crusoe_crewai as crusoe

research_buddy = Agent(
role="Research Buddy",
goal="Help the user by computing things and recalling what you were told to remember.",
backstory=(
"A concise research assistant running on the Crusoe AI Platform. You use tools "
"for calculations and to recall remembered facts, and you answer plainly."
),
# foundry_model() with no args uses the platform default (zai/GLM-5.2
# unless CHAT_MODEL is injected/overridden). Override by name with
# crusoe.foundry_model("nvidia/NVIDIA-Nemotron-3-Super-120B-A12B"), or
# bring your own native model, with your own credentials - the harness
# runs it as-is.
llm=crusoe.foundry_model(),
tools=[crusoe.RunPython(), crusoe.SearchMemory()],
verbose=False,
)

respond = Task(
description=(
"Prior conversation (may be empty on the first turn):\n{history}\n\n"
"Now respond to the user's current message:\n{message}\n\n"
"Use the run_python tool for any calculation, and the search_memory tool to "
"recall things you were told to remember."
),
expected_output="A helpful, concise answer to the user's current message.",
agent=research_buddy,
)

# The module-level object the platform discovers.
crew = Crew(
agents=[research_buddy],
tasks=[respond],
process=Process.sequential,
verbose=False,
)

my-crew-agent/requirements.txt:

# No extra dependencies - crewai, crewai-tools and crusoe_crewai (+ crusoe_core)
# are provided by the harness-crewai base image. Add any agent-specific PyPI
# packages here; they are installed on top of the base image and constrained
# against its frozen dependency set, so a conflicting pin fails at build time,
# not at cold start.

Deploy it

The CLI sees crew.py and auto-detects the framework as crewai.

platformctl deploy ./my-crew-agent --name my-crew-agent

You should see:

packaging ./my-crew-agent...
uploading my-crew-agent (1.9 KiB, framework=crewai)...
build 4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8 accepted
state: -> building
state: building -> deploying
state: deploying -> ready
my-crew-agent is ready at https://my-crew-agent-x7k2q.apps.codyhill.dev

Invoke it: the sync response

Once deployed, a CrewAI agent answers the exact same HTTP API as every other agent — callers can't tell the frameworks apart. This message exercises the model and the sandbox tool in one turn.

platformctl invoke my-crew-agent "Compute 2**32 in python."

You should see:

2**32 is 4294967296.
(session: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d)
tool_call: run_python called with args={'code': 'print(2**32)'}

Full request/response field reference, status codes, and the public-URL variant: invoke an agent.

Invoke it streaming: NDJSON

POST /v1/agents/{name}/invoke/stream takes the same request body and answers with application/x-ndjson — one complete JSON object per line, in the order things happened:

curl -sN -X POST "$CAI_API/v1/agents/my-crew-agent/invoke/stream" \
-H 'content-type: application/json' \
-d '{"message":"Compute 2**32 in python."}'

You should see (one object per line):

{"type":"thinking", "seq":1, "text":"The user wants a computation..."}
{"type":"block_end", "seq":2, "kind":"thinking"}
{"type":"tool_call", "name":"run_python", "args":{"code":"print(2**32)"}}
{"type":"tool_result", "name":"run_python", "result":"4294967296\n"}
{"type":"output", "seq":3, "text":"2**32 is "}
{"type":"output", "seq":4, "text":"4294967296."}
{"type":"block_end", "seq":5, "kind":"output"}
{"type":"done", "session_id":"1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "user_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7"}

Line-by-line semantics, including the error line that arrives instead of done on a failed turn: invoke → streaming.

Sessions: how {history} gets filled

CrewAI itself has no session concept — Crew.kickoff() is stateless across calls. Continuity is entirely platform-constructed: the harness reads the stored canonical event log for the session and the adapter replays earlier turns into your task description's {history} placeholder on every new turn. Your crew's own code does no bookkeeping.

platformctl invoke my-crew-agent "My boat is a Mastercraft Maristar 245."
# -> (session: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d)

platformctl invoke my-crew-agent "What boat do I have?" \
--session 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
# -> You have a Mastercraft Maristar 245.

Reference {history} explicitly (the recommended form, used in the example above) and {message} carries only the current turn. Omit it and the harness folds prior turns into {message} instead, so continuity still holds. Browse the stored transcript with platformctl agents sessions get my-crew-agent <session-id> -o json, or in the Console's Users & Sessions tab. Full mechanics and id rules: sessions.

Memory: memorize a session, recall it in another

Sessions are per-conversation. For facts that must survive across sessions, memorize explicitly, then let the search_memory tool find them from any later conversation:

# 1) Have a conversation, note the session id
platformctl invoke my-crew-agent "My boat is a Mastercraft Maristar 245."

# 2) Commit it to the memory bank (needs sign-in)
platformctl memorize my-crew-agent --session 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d

# 3) A brand-new session — no --session flag
platformctl invoke my-crew-agent "What do you know about my boat?"

You should see:

You have a Mastercraft Maristar 245.
(session: 8f2a1c3e-4b5d-4e6f-9a0b-1c2d3e4f5a6b)
tool_call: search_memory called with args={'query': 'boat'}

Scope, privacy, MEMORY_SCOPE, and the read/write routes: long-term memory.

SDK snippets

There is no generated Agents SDK to install; the invoke endpoint is plain HTTP, so any HTTP client works.

import httpx

API = "https://api.codyhill.dev"
AGENT = "my-crew-agent"

def chat(message: str, session_id: str | None = None) -> dict:
body = {"message": message}
if session_id:
body["session_id"] = session_id
resp = httpx.post(f"{API}/v1/agents/{AGENT}/invoke", json=body, timeout=120)
resp.raise_for_status()
return resp.json()

turn1 = chat("My boat is a Mastercraft Maristar 245.")
turn2 = chat("What boat do I have?", session_id=turn1["session_id"])
print(turn2["output"])

Common patterns

Tool-calling with verification. Give the agent crusoe.RunPython() (and your own @tool functions) and a task telling it to compute rather than guess. Verify a call fired three ways: tool_calls in the sync response, the tool_call/tool_result stream lines, or the function_call parts in the session transcript.

Multi-turn chat. Reference {history} in the task and reuse session_id, always — the two halves of CrewAI continuity: the harness owns replay, you own the placeholders. Your own client stores the id from turn 1 and sends it back on turn 2.

RAG over long-term memory. Teach facts with platformctl memorize, then give the agent crusoe.SearchMemory() and a goal telling it to consult memory. Per-caller privacy is the default; MEMORY_SCOPE=shared pools the bank deliberately. For retrieval over your own documents instead of memorized chat, use Vectors directly — see integration examples.

Agent + MCP Servers. crusoe.mcp_tools() at import attaches every tool your project's MCP Servers publish; see attach MCP Servers.

Multi-agent crews. One crew can hold several Agents and several Tasks under Process.sequential. The whole crew still answers one invoke per turn — split work across roles inside the crew, and keep {history}/{message} in the task that talks to the user.

Debugging checklist

Work top to bottom; each step names the exact string it should confirm or produce.

  1. Did the deploy reach ready? platformctl status my-crew-agent. On failed, the message field holds the tail of the real build output.
  2. Is crew at module level, and does a task reference {message}? A missing crew crash-loops with the harness startup error in the message field. A task without {message} leaves the user's words undelivered.
  3. Did you add a tool and the agent stopped starting? Almost always the sandbox rule: a BaseTool subclass (including most of crewai-tools) cannot be relocated. The error names both the tool and the agent's role — find it in the message field. Rewrite it as an @tool function, or turn the sandbox off deliberately.
  4. Is a model key in force? Publish the agent, then GET <public_url>/debug/config for model_key_present and the resolved model — never the key itself.
  5. Did your tool actually get sandboxed? platformctl logs my-crew-agent --history | grep "tool sandbox" — the startup line names the relocated tools.
  6. Is the turn failing, or the tool inside it? A tool raising in the sandbox does not fail the invoke — the model gets a tool result starting with ERROR: and answers from it. Look for that prefix in the stream or transcript.
  7. Does turn 2 forget turn 1? You changed session_id between calls — or removed both {history} and {message} handling by accident. Reuse the id and keep the placeholders.
  8. Did invoke hang ~60s then 502? Cold start outrunning the invoke timeout — retry; slow imports are the usual cause.
  9. Is search_memory returning nothing? Nothing memorized for that caller yet, or legacy unattributed memories — see the memory page for the MEMORY_SCOPE=shared remedy.

Everything here, with the verbatim error strings, is in troubleshooting.

Limits and costs

LimitValue
Deploy upload100 MiB tarball; one build at a time per agent
Invoke1 MiB request, 32 MiB buffered response, 60 s default timeout
crusoe.RunPython()20 s per execution, DNS-only network
Your own tools (sandboxed)30 s default / 120 s max per call; public internet reachable
MCP attachConnected eagerly at import; an unreachable server fails startup loudly
Conversation stateSessions kept until deleted (admin-settable expiry); the memory bank has no expiry

Cost works like every serverless workload here: an idle agent scales to zero and bills nothing; per-turn cost is model tokens (on your own inference key), plus any sandboxed tool executions. The project quota on total services can refuse a deploy (409) — that's a quota, not a fee. Full list: limits reference.

Clean up

platformctl delete my-crew-agent

Deleting the agent removes its runtime, not its data: sessions and the memory bank survive. The deletion walk is in deploy → clean up.

Next steps

  • Integration examples — CrewAI agent plus Vectors, Memory Store, Secrets, and MCP Servers, end to end.
  • Invoke — sessions, streaming, and the full request/response shapes.
  • Sessions — how {history} gets filled in.
  • Tools — write your own tool functions.
  • Same agent, other frameworks: ADK, LangGraph.