Documentation
Start an episode, run your agent, grade the result.
One API key, one POST, and your agent is inside a fresh instance of real business software. These docs cover the full v1 API, the task format and how verifiers decide pass or fail.
Quickstart
Sign in, create an API key under Settings, and export it as CHARTER_API_KEY. The base URL is https://charterenv.fly.dev/api/v1. An episode is one lease of one fresh instance; it ends when you delete it or when it expires.
export CHARTER_API_KEY="sk_live_your_key"
# 1. Start an episode on a fresh Kimai instance, bound to a task.
curl -s -X POST https://charterenv.fly.dev/api/v1/episodes \
-H "Authorization: Bearer $CHARTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "environment": "kimai", "task": "kimai-001", "ttl_minutes": 60 }'
# 2. If status is "provisioning", poll until it is "ready".
curl -s https://charterenv.fly.dev/api/v1/episodes/EPISODE_ID \
-H "Authorization: Bearer $CHARTER_API_KEY"
# 3. Let the agent work in the instance URL, then grade.
curl -s -X POST https://charterenv.fly.dev/api/v1/episodes/EPISODE_ID/grade \
-H "Authorization: Bearer $CHARTER_API_KEY"
# 4. End the episode. The slot is reset to the seed state.
curl -s -X DELETE https://charterenv.fly.dev/api/v1/episodes/EPISODE_ID \
-H "Authorization: Bearer $CHARTER_API_KEY"import os
import time
import requests
API = "https://charterenv.fly.dev/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['CHARTER_API_KEY']}"}
# Start an episode bound to a task.
res = requests.post(
f"{API}/episodes",
headers=HEADERS,
json={"environment": "kimai", "task": "kimai-001", "ttl_minutes": 60},
)
if res.status_code == 429:
time.sleep(res.json().get("retry_after_seconds", 15))
raise SystemExit("No free slot yet, retry later.")
res.raise_for_status()
episode = res.json()
# Poll until the instance answers.
while episode["status"] == "provisioning":
time.sleep(2)
episode = requests.get(f"{API}/episodes/{episode['id']}", headers=HEADERS).json()
print(episode["task"]["instruction"])
print(episode["url"], episode["credentials"])
# ... run your agent against episode["url"] here ...
grade = requests.post(f"{API}/episodes/{episode['id']}/grade", headers=HEADERS).json()
for check in grade["checks"]:
print("pass" if check["passed"] else "fail", check["name"])
requests.delete(f"{API}/episodes/{episode['id']}", headers=HEADERS)Authentication
Every request carries an organization API key as a bearer token. Keys start with sk_live_, belong to the organization rather than the person who created them, and are shown once at creation. Charter stores only a hash. Revoke a key in Settings and it stops working on the next request.
Authorization: Bearer sk_live_...API reference
All routes live under /api/v1 and return JSON. Timestamps are ISO 8601 in UTC.
| Method | Path | Purpose |
|---|---|---|
| GET | /environments | Catalog of environments, with public tasks. |
| GET | /environments/{id} | One environment, with public tasks. |
| GET | /environments/{id}/tasks | Tasks for one environment. |
| POST | /episodes | Lease a fresh instance, optionally bound to a task. |
| GET | /episodes/{id} | Episode status, URL and credentials. |
| POST | /episodes/{id}/grade | Run the task's verifier against the instance. |
| DELETE | /episodes/{id} | End the episode and reset the slot. |
| GET | /usage | Episode counts and environment-hours this month. |
GET/api/v1/environments
Lists every environment in the catalog with its status (live, building or planned), software version, seed summary and public tasks.
GET/api/v1/environments/{id}
One environment by id, for example kimai, with its public tasks.
GET/api/v1/environments/{id}/tasks
The environment's tasks. Hidden tasks, held out as a private test split, are returned only to a key flagged for that split. v1 has no hidden tasks.
POST/api/v1/episodes
Leases a free slot, recreates its machine from the seeded image so the state is fresh, waits for health, and returns the instance. When the instance already answers, status is ready. Otherwise it is provisioning and you poll GET /episodes/{id}.
{
"environment": "kimai", // required: environment id
"task": "kimai-001", // optional: task id; omit for a free-form episode
"ttl_minutes": 60 // optional: default 60, maximum 180
}{
"id": "ep_...",
"status": "ready", // or "provisioning": poll GET /episodes/{id}
"environment": "kimai",
"task": {
"id": "kimai-001",
"instruction": "..."
}, // null when no task was given
"url": "https://charterenv-kimai-1.fly.dev",
"credentials": { "username": "...", "password": "..." },
"expires_at": "2026-11-02T21:00:00.000Z"
}When every slot for the environment is leased, the call returns 429 with retry_after_seconds. Wait that long and try again.
HTTP/1.1 429 Too Many Requests
{
"error": { "code": "no_free_slot", "message": "Every slot for this environment is leased." },
"retry_after_seconds": 30
}GET/api/v1/episodes/{id}
The episode in the same shape as the create response. status is one of provisioning, ready, grading, graded, ended or failed.
POST/api/v1/episodes/{id}/grade
Runs the task's verifier against the live instance through the software's own API and returns pass or fail with every check. Grading reads state and does not change it.
{
"passed": false,
"checks": [
{
"name": "customer exists",
"passed": true,
"expected": 1,
"actual": 1
},
{
"name": "project budget set",
"passed": false,
"expected": 4000,
"actual": null
}
],
"graded_at": "2026-11-02T20:14:09.000Z"
}DELETE/api/v1/episodes/{id}
Ends the episode. Charter resets the slot to the seed state and releases it. The instance URL stops answering for you once the reset begins.
GET/api/v1/usage
Episode counts and environment-hours for your organization in the current calendar month (UTC). Environment-hours run from the moment an episode is ready to the moment it ends.
Errors
Errors share one shape, with a stable machine-readable code and a sentence for humans. Every call, successful or not, is written to the organization's audit log.
{
"error": {
"code": "not_found",
"message": "Episode not found."
}
}| Status | Meaning |
|---|---|
400 | The body failed validation, for example a ttl_minutes above 180. |
401 | The key is missing, malformed or revoked. |
404 | The environment, task or episode does not exist for your organization. |
409 | The episode is not in a state that allows the call, for example grading an ended episode. |
429 | No free slot. Retry after retry_after_seconds. |
Task format
A task is one JSON file. The agent receives instruction exactly as written. Every task names a concrete end state, has a deterministic verifier, and is solvable only through the software's own interface. Good tasks hinge on multi-step reasoning or a judgment a weak agent gets wrong: a duplicate customer, an archived project, a locked month.
| Field | Type | Description |
|---|---|---|
id | string | Stable id, prefixed with the environment, e.g. kimai-001. |
title | string | Short name shown in the catalog. |
instruction | string | What the agent is told to do. |
difficulty | string | easy, medium or hard. |
tags | string[] | Entities and skills the task touches. |
verifier | object | The verifier spec described below. |
{
"id": "kimai-001",
"title": "Onboard a new client with a capped project",
"instruction": "Create the customer Harbor & Pike Studio (country: United States, currency: USD). Under it, create the project Spring Rebrand with a budget of 4000 USD, and add the activity Design Review to that project only.",
"difficulty": "easy",
"tags": ["customers", "projects", "activities"],
"verifier": {
"checks": [
{
"name": "customer exists",
"query": { "endpoint": "/api/customers", "filter": { "term": "Harbor & Pike Studio" } },
"assert": { "count": 1 }
},
{
"name": "project budget set",
"query": { "endpoint": "/api/projects", "filter": { "term": "Spring Rebrand" } },
"assert": { "path": "0.budget", "equals": 4000 }
}
]
}
}Verifier spec
A verifier is declarative. Each check queries the leased instance through the Kimai REST API (customers, projects, activities, timesheets, users, teams, tags, invoices), then asserts on the result. A task passes only when every check passes. A failing grade names the check that failed with the expected and actual values.
{
"checks": [
{
"name": "...",
"query": { "endpoint": "/api/customers", "filter": { ... } },
"assert": { "count": 1 }
| { "path": "...", "equals": ... }
| { "path": "...", "gte": ... }
}
]
}| Assert | Passes when |
|---|---|
{ "count": n } | The query returns exactly n records. |
{ "path": p, "equals": v } | The value at path p in the result equals v. |
{ "path": p, "gte": v } | The value at path p is a number greater than or equal to v. |
Episode lifecycle
- Lease.
POST /episodestakes a free slot in one database transaction. Each slot is its own Fly app with a stable hostname, so your agent stays pinned to its instance. - Fresh state. Charter destroys the slot's machine and creates a new one from the seeded image. The root filesystem is new, so the seed tenant returns in about 10 to 20 seconds.
- Work. The agent uses the software through the browser or the software's own API, with the credentials from the episode.
- Grade.
POST /episodes/{id}/graderuns the verifier and records the result on the episode. - End.
DELETE /episodes/{id}resets and releases the slot. Episodes expire 60 minutes after ready by default, or afterttl_minutesup to 180. A sweeper resets expired leases.
Environment-hours are metered per episode from ready to ended, billed per minute. See pricing.