Skip to main content

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.

  • platformctl built, signed in, and pointed at your project — see Install the CLI.

  • For the curl tabs, your shell needs the API address and a token:

    export CAI_API=https://api.codyhill.dev
    export 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.

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>.

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.

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.

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 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.

Refused 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 invoke hello-http "ping"

You should see a blank line and a session id:


(session: 3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81)

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 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.

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 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.

A refused exposure does not come back as an error on the call that caused it

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"}
If the public URL doesn't answer

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, statusCode key included.
  • A plain GET calls 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 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.

Those are the log lines from your handler, one per request you made.

Clean up

platformctl delete hello-http

You should see:

deleted hello-http

Next steps