Quickstart: deploy your first function
A function is the smallest thing you can deploy: one file with one handler in it, served as an HTTPS endpoint that scales to zero when idle. In this quickstart you'll write it, deploy it, call it two ways, read its logs, and delete it. Total time: a few minutes.
The platform supports four languages. Pick one in step 2 — every step after that is identical whichever you chose.
Before you begin
-
A platform account and a project — see Create an account.
-
platformctlbuilt, signed in, and pointed at your project — see Install the CLI. -
For the
curltabs, your shell needs the API address and a token:export CAI_API=https://api.codyhill.devexport CAI_TOKEN="<your session token or API key>" -
A Crusoe Cloud access key (an access key ID and its secret key) for step 1, and the admin role in your project so you can save it. Create the key in Crusoe Cloud — the platform never mints one for you. If someone has already connected your project, you need neither.
Step 1: Connect your Crusoe Cloud account
Your function is built into a container image — a self-contained bundle of your code and everything it needs to run — and that image is stored in a repository in your own Crusoe Cloud Registry. Your account, your quota, your bill, and no other customer's images sitting next to yours. So the platform needs a Crusoe Cloud credential for your project before it can build anything, and a deploy without one is refused up front instead of failing halfway through a build.
This is a one-time step per project. If your project is already connected, nothing here changes for you — skip to step 2.
- platformctl
- curl
- Console
Check first — if this says you are connected, go to step 2:
platformctl crusoe-cloud show
An unconnected project answers:
not connected: this project has no Crusoe Cloud credential.
connect one with 'platformctl crusoe-cloud connect --access-key-id <id>'.
So connect it. Put the secret key in a shell variable (or a file), because it is read from standard input and never from a flag:
read -rs CRUSOE_SECRET_KEY # paste the secret key; it is not echoed
printf %s "$CRUSOE_SECRET_KEY" | platformctl crusoe-cloud connect --access-key-id 'CRUSOEEXAMPLEKEYID'
You should see:
connected - the credential was accepted by Crusoe Cloud and stored.
crusoe cloud project: ml-platform
project id: b6f1a0c2-1f2e-4a55-9a4e-2c0a7f8d3e11
region: us-east1-a
There is deliberately no --secret-key flag — a secret passed as an argument lands in your shell history and in the process table. Pipe it in, as above, or point at a file with --secret-key-file ./secret-key. If your key can reach more than one Crusoe Cloud project, add --cc-project-id <id>.
These routes name the project in the path, so you need its id as well as your token. platformctl projects list prints it in the ID column, and the console shows it on the project's page:
export CAI_PROJECT=<your project id>
Check first — {"mapped": false} means you still need to connect:
curl -s "$CAI_API/v1/projects/$CAI_PROJECT/crusoe-cloud" \
-H "Authorization: Bearer $CAI_TOKEN"
Then save the credential. Building the body with jq keeps the secret key out of your shell history and out of the process table:
export CRUSOE_ACCESS_KEY_ID=CRUSOEEXAMPLEKEYID
read -rs CRUSOE_SECRET_KEY # paste the secret key; it is not echoed
jq -n --arg id "$CRUSOE_ACCESS_KEY_ID" --arg key "$CRUSOE_SECRET_KEY" \
'{access_key_id: $id, secret_key: $key}' \
| curl -s -X PUT "$CAI_API/v1/projects/$CAI_PROJECT/crusoe-cloud" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' --data @-
You should see:
{"mapped": true,
"cc_project": {"id": "b6f1a0c2-1f2e-4a55-9a4e-2c0a7f8d3e11", "name": "ml-platform"},
"region": "us-east1-a"}
If your key can reach more than one Crusoe Cloud project the call is refused with 400 — this credential can access N Crusoe Cloud projects; set cc_project_id to choose one — so add cc_project_id and send it again.
- Sign in at https://console.codyhill.dev, pick your project, then go to Project Settings.
- If the page already names a Crusoe Cloud project, you are connected — go to step 2. Otherwise click Map to Crusoe Cloud.
- Paste the Access key ID and Secret key. Leave Crusoe Cloud project blank; that field appears only if your key turns out to reach more than one, and then it offers you a picker.
- Click Map project.
You should see: the dialog closes, a confirmation naming the Crusoe Cloud project, and the page listing your buckets, repositories, and models.
You do not create a repository yourself — the platform creates one per workload the first time it builds it. The secret key is checked against Crusoe Cloud before it is stored, and never returned by any read afterwards. Full detail is in Crusoe Cloud integration.
Step 2: Write the function
Your directory needs one handler file, named for the language. Whatever collection your handler returns — a dictionary, object, map, or hash — becomes the JSON response body.
- Python
- Node.js
- Go
- Ruby
hello-http/handler.py, defining handle(event):
mkdir hello-http && cat > hello-http/handler.py <<'EOF'
def handle(event: dict) -> dict:
print(f"got event: {event}")
who = event.get("name") or event.get("message") or "world"
return {"statusCode": 200, "greeting": f"Hello, {who}!"}
EOF
Use a plain def, never async def — an async Python handler fails with no HTTP response at all, and runtimes explains why.
Add a requirements.txt next to it for pip dependencies. This function needs none.
hello-http/handler.js, exporting handle:
mkdir hello-http && cat > hello-http/handler.js <<'EOF'
'use strict';
function handle(event) {
console.log('got event:', event);
const who = (event && (event.name || event.message)) || 'world';
return { statusCode: 200, greeting: `Hello, ${who}!` };
}
module.exports = { handle };
EOF
Node.js is the one runtime where async function handle(event) also works — the shim awaits your return value.
Add a package.json next to it for npm dependencies. This function needs none.
hello-http/handler.go, with package main and an exported Handle:
mkdir hello-http && cat > hello-http/handler.go <<'EOF'
package main
import "fmt"
func Handle(event map[string]any) (map[string]any, error) {
fmt.Printf("got event: %v\n", event)
who, _ := event["name"].(string)
if who == "" {
who, _ = event["message"].(string)
}
if who == "" {
who = "world"
}
return map[string]any{
"statusCode": 200,
"greeting": "Hello, " + who + "!",
}, nil
}
EOF
The signature must be exactly func Handle(event map[string]any) (map[string]any, error) — your file is compiled into the platform's server at build time, so a wrong signature fails the build rather than the request.
Go is the one runtime with no dependency manifest: your handler may use only the Go standard library.
hello-http/handler.rb, defining a top-level handle(event):
mkdir hello-http && cat > hello-http/handler.rb <<'EOF'
def handle(event)
puts "got event: #{event}"
who = (event['name'] || event['message'] if event.is_a?(Hash)) || 'world'
{ 'statusCode' => 200, 'greeting' => "Hello, #{who}!" }
end
EOF
Note the string key 'statusCode', not a symbol — that is the key the platform reads.
Add a Gemfile next to it for gem dependencies. This function needs none.
That's the whole app. No web framework, no server code, no Dockerfile. The log line goes to the function's logs, which you read in step 6.
Step 3: Deploy it
- platformctl
- curl
- Console
platformctl functions deploy ./hello-http --name hello-http
You should see:
packaging ./hello-http...
uploading hello-http (0.3 KiB, framework=function, runtime=python)...
build 7c41d9e2-... accepted
state: -> building
state: building -> deploying
state: deploying -> ready
hello-http is ready at https://hello-http-ab12cd.apps.codyhill.dev
The runtime was detected from your handler's file name: handler.js means Node.js, handler.go means Go, handler.rb means Ruby, anything else means Python. Name it yourself with --runtime python|nodejs|go|ruby.
The CLI polls every 2 seconds and gives up after 5 minutes — but giving up is the CLI's decision, not the build's. A first Ruby or Node.js build on a cold dependency cache has been seen crossing five minutes and going ready seconds later, so when you see timed out after 5m0s waiting for hello-http, run platformctl status hello-http before you do anything else. Deleting and retrying at that moment tears down a service that was about to work. Raise the wait instead with CAI_DEPLOY_TIMEOUT=10m platformctl functions deploy ./hello-http --name hello-http.
Functions ride the same endpoint as agents. Send framework=function plus the language in a separate runtime field:
tar -czf hello-http.tar.gz -C hello-http .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=hello-http" \
-F "framework=function" \
-F "runtime=python" \
-F "code=@hello-http.tar.gz"
Swap runtime=python for nodejs, go, or ruby to match what you wrote. framework is always the bare word function — the language never goes in that field.
You should see HTTP 202 — the build runs in the background:
{"agent": "hello-http", "build_id": "7c41d9e2-8a1e-4c3b-9d2a-1b2c3d4e5f6a"}
Poll state until it settles on ready or failed — or branch on the ready boolean, which means the same thing on every resource the platform serves:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/hello-http" | jq -r '.state, .runtime, .message'
Sign in at https://console.codyhill.dev, pick your project, go to Compute → Functions and click Deploy function. Name it hello-http, set Language to the one you wrote in step 2, write or upload your source, and deploy. The build panel streams building, deploying, ready.
The Language picker rewrites the starter code, the entry filename, and the dependency tab: handler.py + requirements.txt, handler.js + package.json, handler.rb + Gemfile, and handler.go with no dependency file at all — a Go handler is compiled against the shim's own go.mod, so it may use only the standard library.
Choose the language before you upload. The dialog refuses a deploy whose handler file does not match, with the file it wanted named in the message — "There is no handler.js at the top level of this source, and that is the file the Node.js shim loads. Rename your handler, or change the Language."
One thing the picker does not do is change an existing function: a redeploy keeps the runtime the function was first created with.
409 instead?A message beginning this project cannot deploy yet: means the project has no Crusoe Cloud credential — step 1. Nothing was built and your source was not touched; connect the project and deploy again.
Step 4: Call it through the platform
- platformctl
- curl
- Console
platformctl invoke hello-http "ping"
You should see a blank line and a session id:
(session: 3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81)
curl -s -X POST "$CAI_API/v1/agents/hello-http/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"ping"}'
Invoking takes the same credential as deploying — this platform requires one, and a call without it comes back 401 this platform requires authentication to invoke agents (INVOKE_AUTH_REQUIRED=true).
You should see your handler's own return value, with the session and user ids merged in:
{
"statusCode": 200,
"greeting": "Hello, ping!",
"session_id": "3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
Open the function's page and use its Invoke panel. Send ping; the panel reports the output field, so it shows the same blank answer the CLI does, for the same reason described below.
Two things to unpack there.
First, invoke wraps your text as {"message": "ping"} before delivering it — alongside session_id and user_id, which is why the log line in step 6 carries all three. That's why the handler reads the message key.
Second, the blank line from platformctl invoke is expected, and it is a display difference, not a missing response. The CLI prints only the response's output field, because that's how an agent answers — and a function's return value has no output key. The response itself is your handler's return value, as the curl tab shows. The HTTP status is your handler's too: whatever you put in statusCode becomes the status of the invoke call, so a handler returning {"statusCode": 400, ...} makes this request answer 400.
Step 5: Call it at its public URL
Every workload has one official public address, of the form https://<name>-<project-short>.apps.codyhill.dev. Workloads are private by default, so that address serves nothing until you publish the function.
First find the address:
- platformctl
- curl
- Console
platformctl status does not print it. Its output carries only the fields the CLI itself decodes:
platformctl status hello-http -o json
{
"name": "hello-http",
"framework": "function",
"state": "ready",
"ready": true,
"url": "http://<private-hostname>",
"image": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-hello-http@sha256:9f3c1a2..."
}
The table also carries kind and runtime, and its address column shows the function's public URL — or private when you have not published it. The one field the CLI does not decode is latest_revision; use the curl tab or the console for that.
curl -s "$CAI_API/v1/agents/hello-http" \
-H "Authorization: Bearer $CAI_TOKEN" | jq -r .public_url
You should see:
https://hello-http-ab12cd.apps.codyhill.dev
The function's page shows its public URL on the overview panel, next to its status and runtime.
Then publish it by setting two variables in the function's environment. That change rolls out a new revision — an immutable snapshot of your code and settings, meaning it never changes once created:
- platformctl
- curl
- Console
platformctl agents env set hello-http \
CAI_EXPOSE_EXTERNAL=none \
CAI_EXPOSE_RATE_LIMIT=100/minute
CAI_EXPOSE_EXTERNAL is not a boolean — it names the protection the address gets, and the only three values are apikey, jwt and none. A bare CAI_EXPOSE_EXTERNAL=true is refused by name, because putting an address on the internet without saying who may call it is the decision the platform will not make for you. CAI_EXPOSE_RATE_LIMIT is required whenever exposure is on, written <requests>/<second|minute|hour|day>: a public invoke path with no caller budget is an unbounded spend of your inference key.
This quickstart calls the URL with plain curl, so it uses none — which means exactly what it says: anyone who finds the address can call your function, bounded only by the rate limit. Use apikey for anything you would not hand to a stranger.
agents env is for plain configuration, whose values you can read back. Anything credential-shaped belongs in platformctl secrets set instead. The update is a merge, so variables you don't name keep their values.
curl -s -X PATCH "$CAI_API/v1/agents/hello-http/env" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"set": {"CAI_EXPOSE_EXTERNAL": "none", "CAI_EXPOSE_RATE_LIMIT": "100/minute"}}'
You should see:
{"agent": "hello-http", "env_updated": true,
"env": {"CAI_EXPOSE_EXTERNAL": "none", "CAI_EXPOSE_RATE_LIMIT": "100/minute"}}
CAI_EXPOSE_EXTERNAL names the protection the address gets — apikey, jwt or none — and CAI_EXPOSE_RATE_LIMIT (<requests>/<second|minute|hour|day>) is required whenever it is set. none is what keeps the plain curl below working, and it means anyone who finds the address can call the function, bounded only by that rate limit.
This PATCH does not validate the values. It stores whatever you send and echoes it back with 200, exactly as above. A "true" here would also come back 200 — and then be refused when the platform tried to publish it, which you would see as the function dropping out of ready. See the note below.
The console has no plain-environment editor for a deployed function, so publish it the first-class way instead: go to Networking → Endpoints and click Publish new endpoint.
- What do you want to publish? — pick Function, then
hello-http. - Name this endpoint — type
hello-http, so the address matches thepublic_urlyou found above. - Open Advanced and set Authentication to None — anyone on the internet can call this, which is what lets the plain
curlbelow work. Set the rate limit to100per minute while you are there. - Continue, check the address and the warning on the review pane, then Publish.
Setting the environment answers 200 whatever you put in it; the gateway parses the value later. So a bad value — a bare true, a mode that is not one of the three, a missing rate limit — surfaces one step removed: the function leaves ready, and GET /v1/agents/hello-http reports reason: ExposureRefused with the refusal text in message, e.g. "CAI_EXPOSE_EXTERNAL must name the protection the address gets (CAI_EXPOSE_EXTERNAL=apikey, jwt or none) - a bare yes predates that decision and is refused". It is not a 404 on the URL, and it is not a build failure.
The platformctl shortcut that skips the two-variable dance and prints the address is platformctl gateway publish function/hello-http --auth none --rate-limit 100/minute. --auth is not optional there either: the API answers 400 naming auth.mode if you leave it out.
Wait until state reads ready again, then call the function directly. A POSTed JSON body becomes the event:
export FUNC_URL=https://hello-http-ab12cd.apps.codyhill.dev # your public_url from above
curl -s "$FUNC_URL" -H 'content-type: application/json' -d '{"name":"world"}'
You should see:
{"statusCode": 200, "greeting": "Hello, world!"}
There's also a built-in health check, answered by the platform rather than your handler:
curl -s "$FUNC_URL/healthz"
You should see:
{"status": "ok"}
First check that you published it, as above. An unpublished function is reachable only from inside the platform, and its public address serves nothing.
The API response for a function (GET /v1/agents/{name}) carries two public URL fields. public_url is the official address, and the API returns it even before you publish. external_url appears only once the endpoint is genuinely reachable over a valid TLS certificate — the credential that makes a site trusted over HTTPS — on your install.
platformctl status surfaces neither field. Its output carries only url, the internal address inside the platform. Query the API or check the console to see the public ones.
If external_url takes a moment to provision, DNS registration and TLS certificate issuance automatically complete within a few seconds.
Useful details for later:
- Return
{"statusCode": 404}(plus any other keys) from your handler to control the HTTP status; the default is 200. The whole return value becomes the response body,statusCodekey included. - A plain
GETcalls your handler with an empty{}event. - POST bodies are capped at 8 MiB.
- Functions can also receive events instead of HTTP calls — see HTTP and events.
Step 6: Read the logs
A function that has scaled to zero has no running copy to read logs from. Read the saved lines instead, which survive scale-to-zero.
- platformctl
- curl
- Console
platformctl logs hello-http --history
You should see lines like:
2026-08-12T14:03:05Z stdout got event: {'session_id': '3f6c1f0e-...', 'user_id': '7c9e6679-...', 'message': 'ping'}
2026-08-12T14:05:41Z stdout got event: {'name': 'world'}
Plain platformctl logs hello-http follows the live copy instead, which only works while a copy is running.
Persisted history and the live tail are two different routes. GET /v1/agents/{name}/logs reads the running copy, and is empty once the function has scaled to zero. GET /v1/agents/{name}/logs/history reads the saved lines:
curl -s "$CAI_API/v1/agents/hello-http/logs/history" \
-H "Authorization: Bearer $CAI_TOKEN"
Open the function's Logs tab. It shows persisted history when nothing is running, and follows the live copy when something is.
Those are the log lines from your handler, one per request you made.
Clean up
- platformctl
- curl
- Console
platformctl delete hello-http
You should see:
deleted hello-http
curl -s -X DELETE "$CAI_API/v1/agents/hello-http" \
-H "Authorization: Bearer $CAI_TOKEN"
On the function's page, click Delete and confirm. Its service, its Secret, its environment, and its stored source all go with it, and its URL stops answering immediately.
Next steps
- Crusoe Cloud integration — the rest of what the credential you connected in step 1 unlocks: object-storage buckets, your repository list, and the models your account can serve.
- Functions overview — when to reach for a function versus an agent or a service.
- Runtimes — the full handler contract for Python, Node.js, Go, and Ruby.
- HTTP and events — CloudEvents, status codes, and request shapes.
- Quickstart: deploy your first agent — the five-minute agent version of this page.