Skip to content
cantelopBeta

Build a coding agent API on Cantelop

Give an agent a GitHub repository and a task. Watch it work, send a follow-up, and pick up the same conversation later. This guide takes you from a local checkout to a deployed service with a web console, streaming HTTP API, and optional GitHub issue automation.

View the complete example on GitHub.

What you will build#

The example combines three pieces: Cantelop hosts the API and durable Session workers, OpenCode runs the coding agent, and OpenRouter supplies model access. You choose the model when you create a conversation. There is no OpenAI API key to configure, and these endpoints use their own request format rather than the OpenAI Agents API SDK.

An API request creates a Session actor with its own saved OpenCode conversation. Follow-ups return to that actor. Repository checkouts, queued messages, and results live in a durable Workspace, so closing a browser tab does not discard the conversation.

GitHub webhookRequestAPIMessageSession (OpenCode harness)
One session per conversation. Each turn runs the agent and streams its progress back.

The example shares one workspace across sessions. A filesystem lock serializes complete turns, including checkout, agent tools, and state updates. Each repository has one clone, and each session works on an agent/SESSION_ID branch. Separate conversations do not imply separate filesystems.

The API handles authentication, validation, webhooks, and streaming. The Session worker schedules turns; the runtime handles Git and OpenCode. You can use the included console or build a client against the same API.

1. Prepare your tools#

You need Node.js 22.12 or newer, npm, Git, Docker running locally, and a Cantelop account with the CLI installed. The terminal examples also use curl and jq.

Create an OpenRouter API key with credit and access to your chosen model. Create a fine-grained GitHub token scoped to the repositories you want to use, with Contents: read and write and Issues: read and write.

This example is for one trusted operator. Its API token can access every configured repository and session. Agent tools run without approval prompts and can execute commands, edit files, commit, and push. Use repositories you intend to give the agent access to; the request allowlist does not isolate shell commands or the shared workspace.

2. Configure the example#

Clone the project and install its locked dependencies:

Terminal
git clone https://github.com/stepandel/cantelop-agents-api-example.git
cd cantelop-agents-api-example
npm ci
cp .env.example .env

Edit .env. Fill in API_TOKEN with a long random secret, GITHUB_TOKEN with your scoped GitHub token, and OPENROUTER_API_KEY with your model credential. Set GITHUB_REPOSITORIES to a comma-separated list such as your-name/your-repo,your-name/another-repo.

Set GITHUB_WEBHOOK_SECRET to a second random secret. The app configuration requires it even before you enable webhooks. Run this command separately for each secret and copy the results into .env:

Terminal
node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))'

Keep WORKSPACE_SLUG=agents for your first run. Set GITHUB_ISSUE_MODEL to an OpenRouter model ID available to your account; this is the default for issue-triggered tasks. Leave SESSION_DATABASE_URL and SESSION_DATABASE_AUTH_TOKEN blank for now. The database is optional.

For API-created sessions, the model comes from the creation request. It is a string such as anthropic/claude-sonnet-4.5, subject to availability in your account. The runtime checks the exact ID and does not silently substitute a different model. A conversation retains its initial model; create a new session to change it.

3. Run your first task#

Check the example and start the local container:

Terminal
npm run check
cantelop dev --container

The container installs the example's pinned OpenCode version. Leave it running and check the API from another terminal:

Terminal
curl -fsS http://localhost:8787/health

The expected response is {"status":"ok"}. This confirms the API is reachable; running a task is what verifies GitHub and model access.

Open the local console. In Settings, enter the API_TOKEN from .env. Start a session with an allowed repository, your chosen OpenRouter model, and this first prompt:

Explain this repository's architecture and how to run its tests.
Do not modify files, commit, or push.

Watch the tool progress and wait for the final response. Save the session ID, then ask which tests cover a particular route or module. That follow-up reuses the conversation. The prompt above guides the agent; it does not enforce read-only tool access.

Use Queue for an ordinary follow-up, Steer to interrupt the current turn with new instructions, and Stop to cancel the active turn. Inspect retrieves saved state. If the connection drops, use Reconnect or reopen the session by ID. Closing the browser does not cancel the work.

The console saves its token and local transcripts in browser storage. Without the optional database, its sidebar lists sessions this browser has started or opened by ID.

4. Create and stream a session#

The console is a client of the same API you can call from your application. Export these values in your terminal; editing .env does not export shell variables. Replace the placeholders and choose a model available to you:

Terminal
export BASE_URL='http://localhost:8787'
export API_TOKEN='YOUR_API_TOKEN'
export REPOSITORY='your-name/your-repo'
export MODEL='anthropic/claude-sonnet-4.5'

Create a conversation with POST /sessions, then subscribe to the returned turn stream:

Terminal
request=$(curl -fsS "$BASE_URL/sessions" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg repository "$REPOSITORY" --arg model "$MODEL" \
    '{repository: $repository, model: $model, prompt: "Explain the architecture. Do not modify files, commit, or push."}')")

printf '%s\n' "$request" | jq .
export SESSION_ID=$(printf '%s' "$request" | jq -r .sessionId)
export STREAM_URL="$BASE_URL$(printf '%s' "$request" | jq -r .stream)"

curl -N --fail-with-body "$STREAM_URL" \
  -H "Authorization: Bearer $API_TOKEN"

The creation response is HTTP 202 and contains sessionId, messageId, state, stream, and events. Accepted means dispatch succeeded, not that the agent finished. The stream URL follows this request and closes on its terminal event. The events URL follows the whole session; use messageId to distinguish turns.

Server-sent events carry a replay id, a named event, and a JSON payload. For an agent turn, handle these events:

  • queued / started / status: show admission and progress, including time waiting for the workspace.
  • text.delta: append data.text to the block identified by data.partId.
  • text.replace: replace that block if the agent revises a snapshot.
  • tool.status: show tool activity. Raw tool arguments, outputs, and reasoning are not forwarded.
  • completed: display data.response as the authoritative answer. Do not append it to the streamed text a second time.
  • failed: show the safe diagnostic and stop waiting.

Keep text blocks separate: intermediate explanations are not necessarily part of the final answer. If a stream disconnects, reconnect to the same URL using the last processed event ID:

Terminal
curl -N --fail-with-body "$STREAM_URL" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Last-Event-ID: REPLACE_WITH_LAST_EVENT_ID'

Use event IDs to deduplicate replayed output. Replay follows the platform's retention policy. End-of-file without a terminal event does not prove completion; reconnect or inspect the session. Native browser EventSource cannot set this Bearer header, so browser clients should use fetch streaming or another client that supports authentication headers.

5. Continue or stop a turn#

Send follow-ups to POST /sessions/messages with the saved session ID. Calling POST /sessions again would create a separate conversation.

Terminal
request=$(curl -fsS "$BASE_URL/sessions/messages" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg id "$SESSION_ID" \
    '{sessionId: $id, mode: "queue", prompt: "Which tests cover the API routes?"}')")

curl -N --fail-with-body "$BASE_URL$(printf '%s' "$request" | jq -r .stream)" \
  -H "Authorization: Bearer $API_TOKEN"

Use mode: "steer" to interrupt the active turn, wait for its runtime cleanup, and put the new instruction ahead of ordinary queued messages. The interrupted request ends with failed and data.code: "turn_steered". Changes and external effects already performed remain in place. If the session is idle, either mode starts immediately.

To cancel the active turn:

Terminal
curl -fsS "$BASE_URL/sessions/cancel" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg id "$SESSION_ID" '{sessionId: $id}')"

This returns 202 and its own stream URL. Subscribe as above and look for cancelled; data.cancelled reports whether an active turn was interrupted. Cleanup continues asynchronously. Pending messages remain saved and resume on a later work request.

Call POST /sessions/inspect with the same body to read the durable snapshot. Its stream ends with a session event containing the saved model, conversation ID, status, latest response, and message queue. Inspection works without a database and does not wait for the workspace lock.

6. Deploy to Cantelop#

Set the app field in cantelop.json to your chosen app slug. The example already declares the API, Session worker, and Dockerfile. Create a matching app or use one you own:

Terminal
cantelop login
cantelop app create -slug YOUR_APP_SLUG
cantelop app list

Copy the app ID beginning with app_, then upload the configuration from .env. Replace APP_ID with that ID, not the slug:

Terminal
npm run env:upload -- APP_ID
cantelop doctor
cantelop deploy --dry-run
cantelop deploy

The upload script sends secrets through stdin, uploads only declared settings, and skips blank values. Blank values therefore do not clear existing remote settings. Missing required local settings stop the upload before it changes anything. If a later upload fails, fix the problem and rerun it.

Use the app URL reported by deployment to open the console, enter the production API token, and repeat your first task. Update BASE_URL to that URL, without a trailing slash, for API calls. Local .env changes are not automatically deployed; repeat the upload when changing production configuration.

7. Connect GitHub issues#

Once the service is deployed, configure your repository's Settings → Webhooks:

  • Payload URL: your deployed app URL followed by /webhooks/github.
  • Content type: application/json.
  • Secret: the deployed GITHUB_WEBHOOK_SECRET.
  • Events: Issues.

The API verifies the raw request body with HMAC-SHA256 and checks the repository allowlist. It processes newly opened issues from authors whose association is OWNER, MEMBER, or COLLABORATOR; other actions and authors are ignored.

Issue tasks use GITHUB_ISSUE_MODEL unless a repository rule overrides it. To set a rule, submit an allowed repository and your chosen model:

Terminal
curl -fsS -X PUT "$BASE_URL/github/issue-rules" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg repository "$REPOSITORY" --arg model "$MODEL" \
    '{repository: $repository, model: $model}')"

Follow the returned stream and wait for configured. Model changes apply to future issue sessions; existing conversations retain their model.

The issue workflow asks the agent to implement, test, commit, and push a fix, then post a summary comment. Open a small, reproducible issue in a repository you intend to automate. Watch the run, inspect the agent branch, and review the summary. The example does not automatically create pull requests or merge code.

Receipts deduplicate by repository and issue number, including redeliveries with different delivery IDs. Admission is recorded before agent effects, so a crashed run is not automatically replayed. Check the workspace and GitHub before attempting recovery; admission is not a guarantee of completion or exactly-once pushes and comments.

8. Inspect and operate the service#

For a searchable session list across browsers and webhook runs, configure an optional shared libSQL database. Set the same SESSION_DATABASE_URL, SESSION_DATABASE_AUTH_TOKEN, and WORKSPACE_SLUG for the API, workers, and setup command, then initialize the schema:

Terminal
npm run db:setup
npm run env:upload -- APP_ID
cantelop deploy

The example does not provision a database for you. Once configured, authenticated GET /sessions lists indexed summaries, and GET /sessions/inspect?sessionId=SESSION_ID returns an indexed snapshot without dispatching a worker. Without a database, these GET routes return 503; the POST and streaming flows continue to work.

Workspace JSON is the recovery source. The SQL index updates at lifecycle boundaries, not for every streamed delta, and a newly accepted session may not be indexed until it acquires the lock. See the example's index recovery instructions for backfilling stored sessions after an outage.

When something goes wrong#

  • 401 Unauthorized: use this app's API_TOKEN, not a Cantelop login token or GitHub credential.
  • Repository rejected: match OWNER/REPO against GITHUB_REPOSITORIES and upload changed settings before testing production.
  • Clone or push failed: check token access, Contents permissions, and branch protection.
  • Model failed: inspect the safe diagnostic, exact model ID, OpenRouter key, account credit, and rate limits. Start a new session if the model selection was wrong.
  • Waiting for workspace: another turn may hold the shared lock. Turns run serially and have a 30-minute activity deadline, including lock wait time.
  • A stream ended unexpectedly: reconnect with its last event ID or inspect stored state before retrying work.

A crashed worker can leave .agent-api/workspace.lock behind. It is never automatically expired. Confirm the owning worker and all its tools have stopped before removing it; a PID alone is not proof across containers. Uncommitted changes can also prevent a different session from switching branches. Resolve them through the owning session; the runtime does not reset or stash them automatically.

Where to go next#

You now have a service that accepts tasks, retains conversations, and streams progress to a client. Start with repository questions, then try a bounded change with tests and review the resulting branch. Add webhooks when you want repository events to initiate work.

To extend the example, begin with the source and operating limits, then explore Cantelop's Session runtime, durable Workspaces, and response streaming.