Raw Markdown for agents: https://speed.so/docs/agents/Bring-Your-Own-Agent.md
Bring Your Own Agent
Speed apps are normally built by the Speed agent inside the dashboard. You can also work on one from your own machine, with your own editor or your own coding agent, and get the result onto the platform through the HTTP API: pull the source, run it locally, upload a new version, preview it, deploy it, and read what happened.
This page covers that workflow. The app itself — what it is made of and the
conventions it follows — is covered in
Speed App Development. Both pages are written to be
handed to a coding agent as they are; the raw Markdown is available at the same
URL with a .md extension.
There is no UI for any of this. The endpoints are the ones the dashboard itself uses, so they are stable in practice, but they are not versioned and may change; check back here when something stops working.
Getting started
- Create a personal access token (below) and give it to your agent together with a link to this page.
- Download the app's source, or create an empty app, as described in the next sections.
- Run the app locally in the Speed startkit container, and let the agent read the conventions shipped inside it (see Speed App Development).
- Upload, preview and deploy through the API as described further down.
Authentication
Every request below carries a personal access token as a bearer token. Create
one in the dashboard under Settings → Access Tokens; it starts with spd_, is
shown once, and works until you delete it. The token acts as you, with your role
on each app, so keep it out of the source tree and out of the tarball.
export SPEED_TOKEN=spd_...
App ids and checkpoint ids are numeric; :appId also accepts the app's name.
Starting from scratch
An app does not have to begin with the Speed agent. POST /api/apps with
{"name": "my-app", "empty": true} creates an app whose only checkpoint is the
untouched project template, and runs no agent. The name becomes the app's
<name>.spdso.app domain, so it must be lowercase letters, digits and hyphens.
curl -X POST https://api.speed.so/api/apps \
-H "Authorization: Bearer $SPEED_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "my-app", "empty": true}'
The response is the app plus a checkpointId. Download that checkpoint as
described next to get the template, run it locally, develop it following
Speed App Development, then upload and deploy.
The same template is also inside the startkit image at /tmp/repo, if you want
to look at it before creating an app:
docker create --platform linux/amd64 --name speed-template ghcr.io/xinxiaotech/speed-startkit:latest
docker cp speed-template:/tmp/repo/. ./my-app && docker rm speed-template
rm -rf my-app/node_modules my-app/dist
Getting the source
POST /api/apps/:appId/checkpoints/:checkpointId/download returns the
checkpoint's source as a tar.gz. Download the app's production checkpoint
(productionCheckpointId from GET /api/apps/:appId) to start working on an app
the agent built, or to pick up changes the agent made since your last upload.
curl -X POST "https://api.speed.so/api/apps/$APP_ID/checkpoints/$CHECKPOINT_ID/download" \
-H "Authorization: Bearer $SPEED_TOKEN" -o /tmp/source.tar.gz
Running locally
Run your app in the same Docker image the platform uses, against the production platform services. Requirements: Docker, and your app's source tree.
The image is public and built for linux/amd64, which is what production runs.
On an Apple Silicon machine Docker runs it under emulation; it works, but the
first pnpm install is slower.
docker pull ghcr.io/xinxiaotech/speed-startkit:latest
From the app directory, fetch the Speed SDK assets that the edge proxy serves in production (they are gitignored, and only needed locally):
mkdir -p public/_speed
curl -fsSL https://dash.speed.so/speed-sdk.js -o public/speed-sdk.js
curl -fsSL https://dash.speed.so/speed-service-worker.js -o public/speed-service-worker.js
curl -fsSL https://dash.speed.so/inject-entry.js -o public/_speed/inject-entry.js
Then start the app. Keep the container name speed-dev: the conventions shipped
in the image (see Speed App Development)
refer to it. Add -it if you are running it from an interactive terminal.
docker run --rm --name speed-dev -v "$PWD:/tmp/repo" -p 3000:3000 -p 6006:6006 \
-e NODE_ENV=development -e SPEED_APP_ID=123 -e SPEED_APP_KEY=... \
ghcr.io/xinxiaotech/speed-startkit:latest \
sh -c 'pnpm install && node --import tsx /tmp/framework/server.ts'
The app comes up on http://localhost:3000 and Storybook on http://localhost:6006,
with frontend HMR and API hot reload. The database lives in ./.data, so it
survives restarts and stays out of production.
The database starts empty. On the first start, once pnpm install has finished,
apply the schema; the platform does this for you on upload and deploy, but
locally nothing does:
docker exec speed-dev pnpm run db:apply
SPEED_APP_ID (from the app's dashboard URL) and SPEED_APP_KEY (from
GET /api/apps/:appId, see Other endpoints) are only needed for server-side LLM, image, analytics and upload calls. The
platform endpoints default to production, so nothing else has to be configured.
The bind mount hides the image's node_modules, which is why pnpm install runs
on start. It installs into your app directory, so only the first run is slow —
though that first run does compile better-sqlite3, and takes longer still if
the image's architecture does not match your machine.
Signing in
The normal login flow does not work from localhost: the platform resolves the app
from the domain of the redirect URL, and localhost is not one of your app's
domains. Borrow a real token from the deployed app instead:
- Open
https://<your-app>.spdso.appand sign in. - In the browser console, run:
copy(JSON.stringify(await speed.auth.getCurrentUser())) - Open
http://localhost:3000/?_speed_auth_data=<the JSON, URL-encoded>.
The SDK picks the token up, stores it and strips it from the URL, so
speed.auth.getCurrentUser() works from then on. Its service worker registers on
localhost as it does in production, so your app's own API calls carry the token
without doing anything else. The token expires after a week; repeat the steps to
refresh it.
A real token is what the platform's own endpoints need. Your app's backend is
less strict: in development the framework decodes the token without checking its
signature, so if you only need ctx.var.userId and ctx.var.userRole, any
decodable JWT carrying appId, userId and role will do.
What differs from production
- There is no edge proxy in front of the app, so what it does — access control
for private apps, analytics collection, serving the SDK — either does not
happen or is stood in for by the files you fetched into
public/. - You are running the dev server, not the production build. Run
pnpm run buildbefore uploading if you want to be sure the build passes. node_modulesis installed for the image's platform (Linux), andspeed-frameworkresolves to the framework shipped in the image. Your editor will not resolve those imports; runpnpm run checkinside the container..datahere is your own throwaway database. Uploading source never touches production data.
Uploading a new version
POST /api/apps/:appId/upload-source turns a tarball of the source into a
checkpoint, which can then be previewed and deployed. Nothing runs on your side:
no workspace, no agent session, no Docker.
Packing the tarball
Pack from inside the project directory so the entries have no leading directory component, and leave out what the platform installs or builds for you:
COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata \
--exclude=./node_modules --exclude=./.pnpm-store --exclude=./.git --exclude=./.data --exclude=./dist \
--exclude=./public/speed-sdk.js --exclude=./public/speed-service-worker.js --exclude=./public/_speed \
-czf /tmp/source.tar.gz -C /path/to/app .
The tarball is the whole source tree, not a patch: a file you leave out is a file you delete.
The xattr flags are only needed on macOS, where tar otherwise records
com.apple.provenance on every file and the upload fails with a 500.
Uploading
curl -X POST "https://api.speed.so/api/apps/$APP_ID/upload-source?title=My%20change" \
-H "Authorization: Bearer $SPEED_TOKEN" \
-H "Content-Type: application/gzip" \
--data-binary @/tmp/source.tar.gz
Requires the contributor role on the app. On success:
{
"sessionId": 123,
"checkpointId": 456,
"imageName": "...",
"published": true,
"setupLogs": "..."
}
setupLogs is the output of installing dependencies and applying the schema. If
that step fails — a broken package.json, a schema db:apply rejects — the
response is 422 with the same setupLogs and no checkpoint is created; fix the
source and upload again. A published: false means the checkpoint was created but
its image could not be stored, so deploying it will fail: upload again.
The request runs the install and schema steps, so it can take minutes, and an edge
timeout may cut the response short. The upload itself keeps going — look for the
resulting checkpoint with GET /api/apps/:appId/checkpoints instead of retrying
blindly.
Each upload creates its own session holding a single checkpoint, so an app updated this way accumulates one session per upload.
Previewing
POST /api/apps/:appId/checkpoints/:checkpointId/preview starts the checkpoint
at https://checkpoint-<id>.dev.spdso.app, on a copy of the session data.
?copyDataFrom=production copies production data instead.
Deploying
Deploy the checkpoint with the normal endpoint:
curl -X PUT "https://api.speed.so/api/apps/$APP_ID/deploy?checkpointId=$CHECKPOINT_ID" \
-H "Authorization: Bearer $SPEED_TOKEN"
The deploy runs in the background; the response carries a deployId, and
GET /api/apps/:appId/deploy/:deployId reports status (pending, running,
completed, failed) and error. GET /api/apps/:appId/deployments lists
past deployments.
Two things happen at deploy time rather than at upload time: the production build
(pnpm run build), which makes the first deploy of an uploaded checkpoint slower
than usual, and the migration of the production database from db/schema.ts. The
schema step during upload only touches a throwaway database inside the image, so
production data is never affected by an upload alone.
POST /api/apps/:appId/deploy/dry-run?checkpointId= reports what the migration
would do, without doing it.
Querying the production database
POST /api/apps/:appId/data-viewer (requires contributor) starts a data
viewer for the app's production database and returns its url, the database
path, and an adminerPassword:
curl -X POST "https://api.speed.so/api/apps/$APP_ID/data-viewer" \
-H "Authorization: Bearer $SPEED_TOKEN"
The viewer's sql-runner.php then runs any SQL statement. It is authenticated by
the adminerPassword from the response, not by your access token:
curl -X POST "$URL/sql-runner.php?db=$DATABASE" \
-H "Authorization: Bearer $ADMINER_PASSWORD" -H "Content-Type: application/json" \
-d '{"sql": "SELECT name FROM sqlite_master WHERE type = '"'"'table'"'"'"}'
The response is {"ok": true, "columns": [...], "rows": [[...]], "rowCount": n},
or {"ok": false, "error": "..."}.
This runs directly against the production database, writes and DDL included,
with no confirmation and no undo. Read before you write, and never let an agent
run statements here that it has not shown you first. The same endpoint exists per
session at POST /api/apps/:appId/sessions/:sessionId/data-viewer for that
session's data.
Getting an app token
Your app's own API accepts platform JWTs, not personal access tokens. To call it with curl or from a script, exchange your access token for one:
curl -si -H "Authorization: Bearer $SPEED_TOKEN" \
"https://api.speed.so/platform/auth?redirect=http://localhost:3000/&appId=$APP_ID" \
| grep -i '^location:'
The Location header carries _speed_auth_data, a URL-encoded JSON whose
token is a one-week platform JWT for the app. Use it as the bearer token
against https://<your-app>.spdso.app or your local app, or open the URL in a
browser to sign the local app in directly (see Signing in).
Other endpoints
GET /api/apps/:appId— the app, includingproductionCheckpointId, theappKeyneeded to run the app locally, andenvironments.GET /api/apps/:appId/dev-logs?follow=true— the production container's stdout/stderr as an SSE stream (last 100 lines, then live).PUT /api/apps/:appIdwith{"environments": {...}}— sets the app's environment variables. The object replaces the whole set, so read it fromGET /api/apps/:appIdand send it back with your changes. Each entry is{"value": "...", "description": "...", "prodOnly": false}.
iOS apps
Apps that ship to the App Store have two more pages: Sign in with Apple and Apple In-App Purchases.