Tutorial: research agent with memory
By the end of this tutorial you will have built, deployed, and torn down Research Buddy — an agent that does two things a plain chatbot cannot:
- It computes by writing Python and running it in an isolated sandbox, instead of guessing at arithmetic.
- It remembers — not just within one conversation, but across brand-new conversations that share no history at all.
Every command below is copy-paste-runnable, and every command that prints something shows you what to expect. Budget about 20 minutes.
What you are building
Two kinds of memory show up here, and the difference is the whole point of the tutorial:
- A session is one conversation. The platform stores its turns and replays them to the model on every message, so the agent remembers what you said five minutes ago. When the conversation ends, that context ends with it.
- The memory bank is long-term storage. You explicitly promote a session into it ("memorize"), and from then on any future conversation with that agent can find those facts by searching.
Before you begin
You need:
-
A platform account and a project. There is no self-service sign-up — ask your administrator for an account or an invitation link. See Create an account.
-
Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.
-
platformctltab: the CLI, installed and signed in. See Install the CLI. -
curltab: a token and the API address in your shell:export CAI_API=https://api.codyhill.devexport CAI_TOKEN="<your session token or API key>" -
Console tab: nothing else. A browser is enough.
-
jq(used to pull the session id out of a JSON response). If you do not have it, you can copy session ids by hand instead.
Every act below is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; your choice follows you across every page in these docs.
Check that you are signed in and pointed at the right project:
platformctl whoami
platformctl projects list
You should see:
you@example.com role=user (credential: cached login (~/Library/Application Support/crusoe-ai/token))
SLUG NAME SHORT ROLE ID
ml-team ML Team ab12cd admin 0f7a...-uuid
Pin the project once so you do not repeat --project on every command:
platformctl config set-project ml-team
There are two planes here, and on a stock install both are closed. Changing an agent — deploying, reading logs, setting secrets, memorizing — is the management plane, and it always requires a signed-in account. Talking to a deployed agent is the data plane, gated by INVOKE_AUTH_REQUIRED, which ships on. Invoke without Authorization: Bearer and you get a 401 that names the flag rather than an answer. An install can open the data plane deliberately; do not assume yours has. Every curl tab below sends the header. See API authentication.
You do not need a model API key. The platform wires a managed model into every agent it builds.
Act 1: write the agent
Research Buddy is one file. Which file, and what it must define, is the only thing that differs between frameworks — the platform runs all three unmodified, and every act after this one is identical whichever you pick.
- ADK
- CrewAI
- LangGraph
mkdir -p research-buddy && cat > research-buddy/agent.py <<'EOF'
from google.adk.agents import Agent
from crusoe_adk.foundry import foundry_model
from crusoe_adk.tools import run_python, search_memory
root_agent = Agent(
name="research_buddy",
model=foundry_model(),
instruction=(
"You are Research Buddy, a research assistant. Use the run_python tool "
"for calculations and the search_memory tool to recall things you have "
"been told to remember."
),
tools=[run_python, search_memory],
)
EOF
root_agent— the platform imports youragent.pyand looks for a module-level variable with exactly this name. Rename it and the agent will not start. Full contract: ADK.foundry_model()— returns the platform-managed model. Called with no arguments it follows theCHAT_MODELenvironment variable, so you can switch models later through configuration instead of editing code.run_pythonandsearch_memorycome fromcrusoe_adk.tools.
mkdir -p research-buddy && cat > research-buddy/crew.py <<'EOF'
from crewai import Agent, Crew, Process, Task
import crusoe_crewai as crusoe
research_buddy = Agent(
role="Research Buddy",
goal="Compute things and recall what you were told to remember.",
backstory="A concise research assistant running on the Crusoe AI Platform.",
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}"
),
expected_output="A helpful, concise answer to the user's current message.",
agent=research_buddy,
)
crew = Crew(
agents=[research_buddy],
tasks=[respond],
process=Process.sequential,
verbose=False,
)
EOF
crewis the name the platform looks for — acrewai.Crew, or a zero-argument function returning one. Full contract: CrewAI.- A task description must reference
{message}. That placeholder is how the user's message reaches your crew. Reference{history}too and the platform replays earlier turns into it — which is what makes Act 4 work. crusoe.RunPython()andcrusoe.SearchMemory()are classes, so note the parentheses.
mkdir -p research-buddy && cat > research-buddy/graph.py <<'EOF'
from langgraph.prebuilt import create_react_agent
import crusoe_langchain as crusoe
graph = create_react_agent(
crusoe.foundry_model(),
tools=[crusoe.run_python, crusoe.search_memory],
prompt=(
"You are Research Buddy, a research assistant. Use the run_python tool "
"for calculations and the search_memory tool to recall things you have "
"been told to remember."
),
)
EOF
graphis the name the platform looks for, and it must be a compiled graph over LangGraph'sMessagesState.create_react_agentreturns exactly that. Full contract: LangGraph.- The
MessagesStateshape is what lets the platform replay the conversation into your graph each turn — that replay is what makes Act 4 work. crusoe.run_pythonandcrusoe.search_memoryare plain tool objects, no parentheses.
Two tools do the work in every version, and they are worth naming now:
run_python— when the model calls it, your snippet runs in a throwaway code sandbox that is destroyed afterward and never reused.search_memory— searches this agent's own long-term memory bank and returns the top 5 matching snippets. It is empty right now; that is expected.
You do not need a requirements.txt. Each framework's base image already ships the framework, the Crusoe helpers, and these tools. Add one only if your agent imports a library the base image lacks.
At this point you have: a folder with one Python file in it. Nothing has been deployed.
Act 2: deploy it
- platformctl
- curl
- Console
platformctl deploy ./research-buddy --name research-buddy
You should see:
packaging ./research-buddy...
uploading research-buddy (1.1 KiB, framework=adk)...
build 2f6f1c3a-8b90-4d2e-9f11-6c0a5d7e2b34 accepted
state: -> building
state: building -> deploying
state: deploying -> ready
research-buddy is ready at https://research-buddy-ab12cd.apps.codyhill.dev
The framework on the upload line is whichever one you wrote in Act 1 — the CLI works it out from the entry file in your folder: crew.py means CrewAI, graph.py means LangGraph, anything else means ADK. Force it with --framework adk|crewai|langgraph. While the build runs, the CLI asks for the agent's state every 2 seconds and gives up after 5 minutes.
Confirm the deployment:
platformctl status research-buddy
You should see a table of fields and values including state ready and ready true. The rest are name, framework, the agent's address, image, and created_at / updated_at — plus message if the API set one. Every resource on the platform answers the readiness question the same way: state is the word to show, in this resource's own vocabulary, and ready is the boolean to branch on.
The address shown is the agent's public URL, or private if you have not published it. It is deliberately never the internal hostname, which would look like an address you could call but is not.
The one field platformctl status does not decode is latest_revision. To see revisions, ask for them directly:
platformctl agents revisions research-buddy
They are listed newest first, with the share of traffic each one serves.
Package the directory yourself and post it as multipart/form-data:
tar -czf research-buddy.tar.gz -C research-buddy .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=research-buddy" \
-F "framework=adk" \
-F "code=@research-buddy.tar.gz"
Use framework=crewai or framework=langgraph if that is what you wrote in Act 1. You should see HTTP 202 — the build runs in the background:
{"agent": "research-buddy", "build_id": "2f6f1c3a-8b90-4d2e-9f11-6c0a5d7e2b34"}
Poll until state settles on ready or failed:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/research-buddy" | jq -r '.state, .message'
The full GET response carries more than the CLI table shows, including public_url and latest_revision:
{
"name": "research-buddy",
"state": "ready",
"ready": true,
"kind": "agent",
"framework": "adk",
"image": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-research-buddy@sha256:9f2c1a...",
"url": "http://<private-hostname>",
"public_url": "https://research-buddy-ab12cd.apps.codyhill.dev",
"latest_revision": "research-buddy-00001",
"owner": "you@example.com"
}
- Sign in at https://console.codyhill.dev, press Cmd+K / Ctrl+K to pick your project, then go to Compute → Agents and click Deploy agent.
- Name the agent
research-buddy. - Pick the framework you wrote in Act 1 — ADK, CrewAI, or LangGraph.
- Choose the write mode and replace the starter file with your code, or upload the folder.
- Click through Review & deploy.
You should see: a build panel streaming building, then deploying, then ready. If it lands on failed, the build's own output is shown right there on the agent's Overview tab — that is the build log.
The Overview tab then shows the finished status, framework, image, and public URL.
Here is what happened. Your folder was packed into a .tar.gz and uploaded. The platform built a container image from your code: a packaged copy of your app plus everything it needs to run. That image sits on top of the managed agent harness, the small web server the platform bakes into every agent. Then the platform started the image as a serverless service.
There is no ignore file. A stray .venv/ or a directory of sample data ships with your code and counts against the 100 MiB upload cap. Keep the agent folder to just the agent.
At this point you have: a live, scale-to-zero endpoint running your agent. If the state came back failed, jump to If something breaks before continuing.
Act 3: the first conversation — make it compute
Ask it to do arithmetic and tell it a fact you will test later. Capture the session id the platform mints — you need it in Acts 4 and 5.
- platformctl
- curl
- Console
SESSION=$(platformctl invoke research-buddy \
"My boat is a Mastercraft Maristar 245. Please compute 2**32 in python." \
-o json | jq -r .session_id)
echo "session: $SESSION"
You should see:
session: 3f2c8a1e-9b7d-4e21-a6c0-5d8f13b2e470
Now run the same message without -o json to see it the human way (this starts a different conversation — that is fine, it is just for looking):
platformctl invoke research-buddy "Please compute 2**32 in python."
You should see:
2**32 is 4294967296.
(session: 91b0f4d7-2a6c-4e8f-b3d1-5c7e9a0f2b4d)
tool_call: run_python called with args={'code': 'print(2**32)'}
SESSION=$(curl -s -X POST "$CAI_API/v1/agents/research-buddy/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"My boat is a Mastercraft Maristar 245. Please compute 2**32 in python."}' \
| jq -r .session_id)
echo "session: $SESSION"
The full response body looks like this:
{
"session_id": "3f2c8a1e-9b7d-4e21-a6c0-5d8f13b2e470",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"output": "2**32 is 4294967296.",
"reasoning": "",
"tool_calls": [
{"name": "run_python", "summary": "called with args={'code': 'print(2**32)'}"}
],
"events": ["..."]
}
That call carries the same Authorization header as every other one on this page. Invoking is the data plane, and INVOKE_AUTH_REQUIRED ships on: drop the header and the reply is a 401 naming the flag instead of an answer. An app calling this agent needs a credential of its own — an API key from a service account, not your interactive login. See service accounts and API keys.
For a live, token-by-token response instead of one JSON blob, POST the same body to /v1/agents/research-buddy/invoke/stream — details in invoke.
Open the agent's Test tab and send:
My boat is a Mastercraft Maristar 245. Please compute 2**32 in python.
You should see: a reply containing 4294967296, a tool call entry showing run_python ran, and the session id for this conversation. The Test panel keeps reusing that session for follow-up messages, so there is nothing to copy for Act 4.
Three things to notice:
- The number is exactly right. The model did not do the arithmetic — it wrote
print(2**32), the sandbox executed it, and the real answer came back. tool_call: run_pythonis the receipt. Every tool the agent used is listed in the response, so you always know whether an answer came from computation or from the model's own words.- The platform minted a session id and printed it back. Omit
--sessionand you get a brand-new conversation every time.
The sandbox that ran your code was created for that one call and destroyed afterward. It runs as a non-root user and holds none of your agent's credentials. It cannot reach the network either, apart from DNS lookups, so model-written code cannot phone home. See security and limits.
At this point you have: proof that the agent computes rather than guesses, and a session id in $SESSION holding the boat fact.
Act 4: short-term memory — continue the conversation
Send a follow-up into the same session:
- platformctl
- curl
- Console
platformctl invoke research-buddy "What boat do I have?" --session "$SESSION"
You should see:
You have a Mastercraft Maristar 245.
(session: 3f2c8a1e-9b7d-4e21-a6c0-5d8f13b2e470)
curl -s -X POST "$CAI_API/v1/agents/research-buddy/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d "{\"message\":\"What boat do I have?\",\"session_id\":\"$SESSION\"}" | jq -r .output
You should see:
You have a Mastercraft Maristar 245.
In the same Test panel, send a follow-up:
What boat do I have?
The Test panel reuses the same session for you — there is nothing to copy. You should see a reply naming the Mastercraft Maristar 245.
The model was never told the boat's name in this message. The platform stored turn 1 and replayed the whole conversation to the model for turn 2. That is a session doing its job.
Now prove the negative — start a fresh conversation and ask the same question:
- platformctl
- curl
- Console
platformctl invoke research-buddy "What boat do I have?"
Omit session_id entirely and the platform mints a new one:
curl -s -X POST "$CAI_API/v1/agents/research-buddy/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"What boat do I have?"}' | jq -r '.session_id, .output'
Click New session in the Test panel, then ask the same question again.
You should see: a reply along the lines of "I don't have that information" or a request for more detail, plus a different session id. Sessions do not leak into each other. Each one starts blank.
That gap is exactly what the memory bank exists to close.
Let the platform mint your session ids. Signed-in callers — which is everyone in this tutorial — may name a session anything. The floor applies only where an install has opened the data plane and a caller invokes with no credential: that caller's own session_id must be at least 24 characters of unguessable randomness, and anything shorter gets a 400 explaining why. Whoever names a session owns its history, so a short, guessable id ("s1", "test") would let anyone else read the conversation back. Details in invoke.
At this point you have: seen both halves of short-term memory — continuity inside one session, and a clean slate between sessions.
Act 5: promote the conversation into long-term memory
A new agent's memory is off: it forgets everything between sessions until you decide otherwise. Whether an agent remembers is a policy on the agent - off, auto (distil facts from every conversation) or explicit (only when asked). Turn it on as explicit, since the next step is asking:
platformctl agents memory set research-buddy --mode explicit
You should see:
mode explicit
a new revision is rolling; the running agent serves the new policy once it is ready
Wait for platformctl agents memory status research-buddy to report mode explicit - a policy change rolls a new revision - then memorize. Memorizing reads the session you built in Act 3 and distils it into facts about you, stored per user and searchable in any later conversation:
- platformctl
- curl
- Console
platformctl memorize research-buddy --session "$SESSION"
You should see:
memorized session 3f2c8a1e-9b7d-4e21-a6c0-5d8f13b2e470 for research-buddy
The --session flag is required. Leaving it off fails with --session is required.
Like every other call here, this one must carry your token:
curl -s -X POST -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/research-buddy/sessions/$SESSION/memorize"
The older "memorize": true flag on an invoke still works for one more release (under explicit it means "distil this whole session now"), but it is deprecated: the policy on the agent is the mechanism, and this route is the operator's override. Under off the agent answers 409 naming the setting.
Open Memory settings on the agent and choose explicit, then in the Test panel use Remember this to store a fact about the conversation's user, as you write it - for example Owns a Mastercraft Maristar 245. The badge next to it shows the policy in force.
Three things happened under the hood. The agent's own model read the session and wrote short, standalone facts about you ("Owns a Mastercraft Maristar 245."). Each fact was turned into an embedding — a list of numbers that captures its meaning — and stored, tagged with your user id, in the memory collection that belongs to this agent. Under explicit nothing is memorized until someone asks; under auto this happens after every reply.
Two rules worth internalizing now, because both surprise people later:
Writing memory requires a signed-in caller even though invoking does not: a stored fact is read back into that user's later conversations. Memory is filtered per user - a fact about you is never served to anyone else - so the thing to watch is the other direction: whoever sends the user_id decides whose memory a conversation writes to. You can see and erase what was stored at any time with platformctl agents memory list and forget.
At this point you have: one memory in the bank. The next act reads it back.
Act 6: long-term memory — recall in a brand-new conversation
Start a conversation with no history at all, and ask about the boat:
- platformctl
- curl
- Console
Leave off the --session flag:
platformctl invoke research-buddy "What do you know about my boat?"
You should see:
You have a Mastercraft Maristar 245.
(session: 7c1e5b93-4f28-4a0d-9e63-2b8a1c4d6f07)
tool_call: search_memory called with args={'query': 'boat'}
Omit session_id:
curl -s -X POST "$CAI_API/v1/agents/research-buddy/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"What do you know about my boat?"}' \
| jq -r '.session_id, .output, .tool_calls[].name'
You should see:
7c1e5b93-4f28-4a0d-9e63-2b8a1c4d6f07
You have a Mastercraft Maristar 245.
search_memory
Click New session in the Test panel, then ask:
What do you know about my boat?
You should see: the right answer, a search_memory tool call entry, and a brand-new session id.
Compare that with the identical question in Act 4, which came back empty. The session id is new and the conversation history is empty, yet the answer is right. The model called search_memory, which searched the bank by meaning rather than by keyword and found the memorized snippet.
That is the payoff: an agent that accumulates knowledge instead of forgetting everything the moment a chat window closes.
At this point you have: a working end-to-end memory loop — teach in one session, memorize, recall in any later session.
Act 7: watch it fall asleep and wake up
Leave the agent alone for a few minutes, then look at its logs:
- platformctl
- curl
- Console
platformctl logs research-buddy
You should see, once it has gone idle:
agent research-buddy has no running instances: it is scaled to zero, which is normal for an idle serverless agent - it cold-starts on the next invoke. For logs from earlier runs, use GET /v1/agents/<name>/logs/history.
That is not an error. An idle agent runs zero machines. To read logs from earlier runs anyway:
platformctl logs research-buddy --history
Now invoke once more:
platformctl invoke research-buddy "Are you awake?"
Live logs come from the running instances, so an idle agent has none to give:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/research-buddy/logs"
Persisted history is a separate route, and it answers even at zero:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/research-buddy/logs/history"
Then wake it up:
curl -s -X POST "$CAI_API/v1/agents/research-buddy/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"Are you awake?"}' | jq -r .output
Open the agent's Logs tab. While the agent is idle it reports that there are no running instances; switch to the persisted history to read logs from earlier runs.
Then send any message from the Test tab to wake it up.
Persisted history is kept for 14 days by default, in lines of the form <timestamp> <stream> <message>. It survives both scale-to-zero and new revisions, which is why it is the right thing to reach for when you are debugging something that has already finished.
The first reply after an idle period is slower. The platform has to start a machine before your message can be answered. That extra wait is a cold start, and it is the price of not paying for idle capacity. See autoscaling and scale-to-zero.
At this point you have: seen the full lifecycle, including what "serverless" actually feels like.
Clean up
Delete the agent. This removes the running service, its per-agent secrets and environment configuration, and its stored source:
- platformctl
- curl
- Console
platformctl delete research-buddy
You should see:
deleted research-buddy
curl -s -X DELETE -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/research-buddy"
You should see:
{"agent": "research-buddy", "deleted": true}
On the agent's page, click Delete and confirm.
Delete is narrower than it looks: it removes the running agent, not the conversations it stored or anything it memorized. See what delete leaves behind before you rely on it to remove data.
If you pinned a default project only for this tutorial, clear it:
platformctl config set-project ""
Deleting is irreversible. Manage memory programmatically through agent session memory operations (memorize and search_memory).
If something breaks
| Symptom | Cause and fix |
|---|---|
State is failed right after deploy | The build failed, or the agent failed to start. The failure output lands in the agent's message field. Run platformctl status research-buddy to see it, or open the agent's Overview tab in the console, which renders it in full. There is no separate build-log endpoint. |
could not import 'root_agent' from /app/agent/agent.py | Your agent.py does not define a module-level root_agent. Match Act 1 exactly. |
invalid agent name (must be a lowercase DNS label) | Names are lowercase letters, digits, and hyphens, must start with a letter, and are at most 63 characters. |
this is a management endpoint and requires authentication... | You are not signed in. Run platformctl login, or send Authorization: Bearer $CAI_TOKEN on the HTTP call. |
this platform requires authentication to invoke agents (INVOKE_AUTH_REQUIRED=true) | The invoke carried no credential. Send the same header — invoking needs one too, unless your install sets INVOKE_AUTH_REQUIRED=false. |
--session is required | platformctl memorize needs the session you want to commit. |
| Turn 2 forgot turn 1 | You did not pass --session, so each invoke started a new conversation. |
A 409 mentioning "exists in more than one project" | The same agent name lives in two projects you can see. Add --project <slug>, or ?project=<slug> on an HTTP call. |
this project is at its service limit | Your project has hit its service quota. Delete an agent or function, or ask an admin to raise the quota. See quotas and audit. |
| The reply is a model authentication error | No model key is configured for your install. Ask your administrator, or set your own with platformctl secrets set research-buddy MODEL_API_KEY=... — see secrets and env. |
More symptoms and fixes: Agent troubleshooting.
What you learned
| Idea | The one-sentence version |
|---|---|
| Agent | A folder of Python code the platform builds and runs as a scale-to-zero HTTPS endpoint. |
| Tool | A function the model can decide to call; run_python and search_memory are built in. |
| Session | One conversation, replayed to the model each turn — short-term memory. |
| Memorize | The explicit step that turns a session into long-term, searchable knowledge. |
| Memory bank | Per-agent long-term storage, searched by meaning, shared by every future caller. |
| Scale-to-zero | Idle agents run zero machines; the next request pays a cold start. |
| Data vs management plane | Changing an agent always needs a sign-in; talking to one does too, unless the install sets INVOKE_AUTH_REQUIRED=false. |
Next steps
- Tutorial: RAG chatbot — ground an agent in your own documents instead of in past conversations.
- Tutorial: weather tools over MCP — publish a tool once and share it across agents.
- Sessions — user ids, transcripts, and browsing conversations.
- Long-term memory — the memory bank in depth.
- Built-in tools — writing your own tools, and where their code runs.
- Traffic and revisions — how every change becomes an immutable revision you can roll back to.
Go deeper
These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:
| Guide | Framework / language |
|---|---|
| Multi-step research agent | LangGraph |
| Editorial pipeline with a crew | CrewAI |
| Support agent over your own docs | ADK |
| Document ingestion pipeline | Python |
| Webhook fan-out, exactly once | Node.js |
| Scheduled reconciliation job | Go |
| Object-store ETL with move-after-read | Ruby |