Coding runs
A coding run gives an agent a GitHub repository and a task. The agent works on a branch of its own in a sandbox, pushes it and opens a pull request; your app reviews the change, sends findings back, asks for a preview a person can judge, and merges. This guide follows one task from the first call to the merge. Every endpoint it uses is in the reference, and every event in the event catalog.
Before you start
- The approved tier. A coding run in this guide is a
standardrun on the approved tier. During the private beta every app is approved before it can run anything, so if your app can start a run, it is on the approved tier. Once sign-up opens to everyone, asandboxapp can follow this guide onsmallwithout the preview step, with its lifecycle and budget inside the sandbox limits; recorded previews with Playwright need the approved tier. - A workspace for your customer, with their Anthropic key, as in steps 2 and 3 of the quickstart.
- A GitHub App of yours, installed on your customer's repository, to mint the tokens the agent pushes with (Credentials and refresh).
- A webhook endpoint. The default subscription carries every event
this guide waits for; add
tool.completedif you want to show the agent's steps as they happen. - An API key with these scopes:
| Scope | Used for |
|---|---|
workspaces:write |
Creating your customer's workspace. |
credentials:write |
Storing their Anthropic key. |
bundles:write |
Publishing the coding agent. |
runs:write |
Starting runs, sending turns, setting run secrets and completing runs. |
runs:read |
Reading runs and turns back. |
events:read |
Reading a run's events. |
files:read |
Downloading the files a run leaves. |
webhooks:write |
Registering your webhook endpoint and your credential-refresh URL. |
A write scope does not imply read. Scopes lists them all.
Protect the default branch
The agent pushes with a token that can write to the repository. What keeps its work off the default branch until your app merges it is a rule on GitHub, so Forgeling runs an agent only against a repository whose default branch blocks direct pushes.
Before it provisions a run, and again at every resume, Forgeling reads the repository's default branch with the run's own credential and looks for a rule that blocks direct pushes to it:
- an active ruleset with a
pull_requestrule for that branch (GitHub'sGET /repos/{owner}/{repo}/rules/branches/{branch}), or - where the credential can read it, classic branch protection that requires pull-request reviews.
A protected flag alone is not enough: it says that some rule exists, not that the rule blocks a push.
When no such rule can be verified, the run is refused and ends with run.failed, code
repo_policy_unmet. At a resume, the queued turn fails with repo_policy_unmet and the run is torn down
with the same run.failed code, so removing the protection while a run waits stops the run.
Repositories must be on github.com; a repository on any other host is 422 repo_host_unsupported.
A ruleset to start from
Set these up in the repository's rulesets, or in your customer's organization rulesets:
- A branch ruleset on the default branch with Require a pull request before merging. This is the rule the check looks for. Keep Restrict deletions and Block force pushes on.
- A branch ruleset on every branch except your run branches' prefix, such as
story/**, with Restrict creations. The agent can then create branches only under that prefix. - A tag ruleset on all tags with Restrict creations, which blocks tag creation.
The token needs contents:write to push the run's branch, and that also lets it push other branches and
tags; rulesets 2 and 3 take that away. pushed_refs on run.completed lists the refs the run saw itself
push, but like everything the sandbox reports it proves origin, not truth: compare it with GitHub.
Never add your GitHub App to a bypass list, of these rulesets or of branch protection. The agent pushes with your App's token, so a bypass lets it push straight to the default branch, and Forgeling's check cannot see bypass lists.
Unprotected repositories
Some repositories cannot block direct pushes: a private repository on GitHub Free can have neither rulesets nor branch protection. Such a repository is refused by default. It may run only if an admin of your customer's org explicitly opts in for that repository, acknowledging that the agent could push straight to the default branch. The opt-in belongs to that admin, not to you as the app's developer. Repositories without push protection on the security page states the rule.
When an admin has opted in, add repo_policy and repo_policy_ack to the repository resource of each
run on that repository:
{
"type": "git_repository",
"url": "https://github.com/acme/shop",
"ref": "main",
"branch": "story/42/sso-login",
"credential_secret": "GITHUB_TOKEN",
"repo_policy": "allow_unprotected",
"repo_policy_ack": {"by": "usr_8812", "at": "2026-11-02T09:14:00Z"}
}
repo_policyis"require_push_block", the default, or"allow_unprotected".repo_policy_acksays who opted in, as your app's own id for that admin, and when.allow_unprotectedwithout it is 422repo_policy_ack_required.
The check still runs on such a run. When it fails, the run goes ahead instead of failing, and the result
is recorded: run.ready reports repo_policy_check for the repository. Forgeling cannot verify the
admin. Your app attests the opt-in, and the attestation is stored with the run and audited under the API
key that sent it. Every event of the run carries "repo_policy": "allow_unprotected" in its envelope,
and pushed_refs on run.completed lists every ref the agent pushed, so you can see a push to the
default branch.
Your app owes the admin and your users three things:
- Say what the admin accepts, in plain words, before they opt in: the agent will be able to push code straight to the default branch, without a pull request or a review.
- Label the work. Mark every task whose code ran without push protection, wherever your users see it, so a reviewer knows.
- Check before you merge. Compare
pushed_refsand the default branch's history with GitHub.
Credentials and refresh
The agent clones, pushes and opens the pull request with a token you give the run as a run secret. Use a
GitHub App installation token for the one repository, with contents:write and pull_requests:write,
which GitHub makes valid for an hour. Mark it refreshable, with its expires_at, and Forgeling asks
your app for a new one before it expires.
The token never lands in a URL or in the repository's Git config. Git reads it through a credential
helper and gh through a wrapper, each from the run secret's file at the moment of the call, so a
refreshed token is used from the next call on, even in the middle of a turn.
Register the URL Forgeling calls for a new value. One URL serves your whole app:
curl -X PUT https://api.forgeling.io/v1/app/credential_refresh \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/credential-refresh"}'
The answer is the credential_refresh object, with "status": "enabled". The first PUT also answers
with the URL's signing secret, secret (whsec_…), shown only in that answer. It is the URL's own
secret, not one of your webhook endpoints'. POST /v1/app/credential_refresh/rotate_secret makes a new
one.
10 minutes before a refreshable secret's expires_at, and at a resume if it has already expired,
Forgeling sends your URL a signed POST:
{
"type": "credential.refresh",
"run": {"id": "run_01M6GYS6C0XNXDF714KDYE7SQ2", "metadata": {"job_id": "job_42"}},
"workspace": {"id": "ws_01M6GYS6C059AAC83G7BWCMBMM", "external_id": "org_42"},
"secret": "GITHUB_TOKEN",
"expires_at": "2026-11-03T16:00:00.000Z"
}
It carries the same Standard Webhooks headers as a webhook delivery, with webhook-id: crf_…, and is
signed with the URL's own secret: verify it with the same function before you mint anything
(Verify the credential-refresh call). The run's id and the
metadata you gave it, and the workspace's external_id, tell you which customer and repository to mint
a token for. Answer 200 with the new value and when it expires:
{"value": "ghs_…", "expires_at": "2026-11-03T17:00:00.000Z"}
Forgeling stores the new value encrypted and writes it into the sandbox, and Git and gh use it from
their next call. Your app need not be running when the call comes: it is an inbound request, so it wakes
an app that scales to zero.
When the call fails:
- Forgeling retries with backoff until the secret expires. Then it emits
secret.refresh_failed, with the secret'snameandexpires_at. The run itself goes on, but once the token has expired, Git andghfail to authenticate until you set a new value withPUT /v1/runs/{run}/secrets/{name}. - Without an enabled credential-refresh URL, a refreshable secret ends the same way, with
secret.refresh_failedat itsexpires_at. - After 72 hours in which every call failed, Forgeling disables the URL and emails your app's
members.
PUTthe URL again to enable it.
Publish the coding agent
Publish the agent definition once and start every run from it. The instructions name the run's paths by their environment variables, and the tools are the usual coding set:
curl https://api.forgeling.io/v1/bundles/coding-agent/versions \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": "1.0.0",
"spec": {
"model": "claude-sonnet-4-5",
"instructions": "You implement one task in a Git repository. Read $RUN_INPUTS/task.md and $RUN_INPUTS/context.md. Work in $RUN_REPO on the branch that is checked out. Run the tests. Commit, push and open a pull request with gh. Call submit_result when you are done.",
"tools": {
"allow": ["Bash", "Read", "Edit", "Write", "Glob", "Grep", "WebFetch", "Agent"],
"deny": ["Edit(run/repo/.github/workflows/**)"]
},
"secrets": ["GITHUB_TOKEN", "PREVIEW_USER", "PREVIEW_PASSWORD"],
"prompt_profile": "task"
}
}'
The deny rule keeps the agent's Edit tool away from your customer's CI workflows.
Deny rules are not a security boundary when Bash is allowed. A shell can edit any file a deny rule protects.
What protects the default branch is the rule that blocks direct pushes, and your review before you merge.
secrets only documents the names the runs pass: a run secret works without being declared. Wait for
bundle.version_ready, or check GET /v1/bundles/coding-agent/versions/1.0.0, before you start a run.
Agent definitions covers every field, the
tool rules and runtime pinning.
Start the run
Mint an installation token for your customer's repository, then start the run with it:
curl "https://api.forgeling.io/v1/workspaces/$WS/runs" \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-42-implement" \
-d '{
"definition": {"bundle": "coding-agent", "version": "1.0.0"},
"size": "standard",
"approvals": {"mode": "deny"},
"resources": [{
"type": "git_repository",
"url": "https://github.com/acme/shop",
"ref": "main",
"branch": "story/42/sso-login",
"path": "repo",
"credential_secret": "GITHUB_TOKEN",
"author": {"name": "Your App", "email": "bot@example.com"},
"attribution": "none"
}],
"secrets": {
"GITHUB_TOKEN": {"value": "ghs_…", "expires_at": "2026-11-03T16:00:00.000Z", "refreshable": true}
},
"files": [
{"path": "inputs/task.md", "content": "…"},
{"path": "inputs/context.md", "content": "…"}
],
"budget": {"max_cost_usd": 20},
"lifecycle": {"idle_suspend_s": 1800, "expires_in_s": 259200},
"metadata": {"job_id": "job_42"},
"initial_turn": {
"input": [{"type": "text", "text": "Implement the task in inputs/task.md."}],
"context": {"task": "42", "acceptance_criteria": ["…"]},
"output_schema": {
"type": "object",
"required": ["pr_url", "head_sha", "summary", "ui_visible"],
"properties": {
"pr_url": {"type": "string"},
"head_sha": {"type": "string"},
"summary": {"type": "string"},
"ui_visible": {"type": "boolean"}
}
},
"metadata": {"stage": "implement"}
}
}'
resourcesholds the repository, one per run. Forgeling clones it into the run root before the first turn, outside the model. Ifbranchalready exists on GitHub, its tip is checked out; otherwise it is created fromref.branchmust differ fromref: the agent works on a branch of its own, never on the one it starts from. The clone fetches the last 50 commits;"depth": nullfetches them all.credential_secretnames the run secret that Git,ghand the protection check use.authorsets the Git author and committer of the agent's commits, and"attribution": "none"turns off the commit and pull-request trailers the CLI would add.secretsvalues are strings, or objects withvalue,expires_atandrefreshable.filesare written into the run root before the first turn (The run root).budgetstops the run's turns once it has spentmax_cost_usd;budget.warningcomes at 80%.lifecyclesuspends the run after 30 idle minutes and ends it after 72 hours at most.initial_turnis the first turn. Itscontextis trusted JSON, which the model sees labelled with your app'sdisplay_name. Itsoutput_schemagives the agent asubmit_resulttool to return a result of that shape, which arrives asresultonturn.completed.Idempotency-Keyis required. A retry with the same key and body gets the same run back.
Forgeling checks the request at once: a field it refuses is 422, as are a definition version that is not
ready or is yanked (bundle_version_unavailable) and a size or limit above your tier (tier_limit).
The answer is 202 with the run and its first turn. Keep the run's id as RUN. Then Forgeling checks
the default branch, starts the sandbox, clones the repository and starts the turn. The run's events, in
order: run.queued, run.provisioning, run.ready (with the repository's default_branch, branch
and head_sha), run.running, turn.started, tool.completed for each tool call if you subscribe to
it, turn.completed with the result, run.idle, and output.created for each file in the outputs.
The run root
Everything a run works with sits under one directory, the run root:
| Path | What |
|---|---|
inputs/ |
The files you passed. |
repo/ |
The repository, at the resource's path. |
outputs/ |
What the agent leaves for you. Forgeling collects it (Outputs). |
secrets/ |
One file per run secret. Never collected. |
The agent's prompt states the absolute paths, and the agent and every command it runs have them as environment variables:
| Variable | Value |
|---|---|
$RUN_ROOT |
The run root. |
$RUN_INPUTS |
$RUN_ROOT/inputs |
$RUN_OUTPUTS |
$RUN_ROOT/outputs |
$RUN_SECRETS |
$RUN_ROOT/secrets |
$RUN_REPO |
The repository, $RUN_ROOT/<path>, when the run has one. |
$RUN_ID |
The run's id, run_…. |
Write paths in your instructions with these variables. A path you send or get back through the API, such
as a files path, a File's path or a path in a turn's result, is relative to the run root:
outputs/preview.webm.
Files. Each entry of files takes a path and either content (UTF-8 text) or content_base64,
exactly one. "root": "workspace" places a file relative to the agent's working directory instead,
outside the run root. A path under secrets/ or under the repository's path is refused. The inline
files of one run add up to at most 5 MB.
Secrets. Each run secret reaches the agent in two ways: as an environment variable of that name, set
when a turn starts, and as the file $RUN_SECRETS/<NAME> (mode 0600), which is current at every read. A
secret set or refreshed during a turn is in the file at once and in the environment from the next turn;
Git and gh read the file. Run secrets are stored encrypted until the run is torn down, and deleted
then. The agent can read every one of them, as it can read the workspace's Anthropic key, so give each run
short-lived tokens scoped to it (What a run can read and reach).
A secret's name is upper-case letters, digits and _, at most 64 characters, not starting with a digit.
It may not start with
ANTHROPIC_, FORGELING_, PACT_, PACTD_, RUN_ or CLAUDE_, and may not be HOME, PATH,
TMPDIR, GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL,
DISABLE_TELEMETRY or DISABLE_ERROR_REPORTING, which the platform sets in the agent's environment.
Starting a run with such a name is 422 validation_failed.
The sandbox. A run has no root, no apt and no Docker. It has Git, gh, Node.js 22, uv and
uvx, ffmpeg, and Playwright with Chromium; install what the repository needs in user space, with npm,
pip or uv. HOME and TMPDIR are on the run's disk. Services a preview needs, such as a database, a
cache or a login provider, come from your customer's preview deployment, not from the run
(Previews). A run reaches the public internet, but not private networks, the cloud metadata
server or outgoing mail (SMTP), and nothing can connect in to it.
Outputs
The agent leaves files for you in $RUN_OUTPUTS. Forgeling collects them from outside the sandbox each
time the run goes idle and when it is torn down: regular files only, up to 200 files and 500 MB each
time. Each collected file is an output.created event carrying its File object, and a run's files can
be read while it is still going:
curl "https://api.forgeling.io/v1/runs/$RUN/files" \
-H "Authorization: Bearer $FORGELING_API_KEY"
The run itself, GET /v1/runs/{run}, shows the latest file at each path.
GET /v1/files/{file}/content answers 302 with a
5-minute signed URL on storage.googleapis.com, which downloads the file as an attachment. The URL's
signature names the hosting project, so re-host files for your users: download each file and serve it
from your own storage, and never hand the signed URL on. A file can be downloaded until its expires_at.
The review loop
When the turn ends, turn.completed carries the agent's result: the pull request's URL, its head SHA,
a summary, and whether the change is visible in the UI. Events prove origin, not truth: the sandbox
reports the result and the cost_usd, and code in the repository could have written them. Before you
review, re-read the pull request from GitHub: its repository, that its base is the default branch, and
its head SHA. Review that SHA.
A run that stays idle for idle_suspend_s (here 1800 seconds) suspends: Forgeling collects its outputs,
stops its sandbox but keeps its disk and the agent's context, and emits run.suspended. A suspended run
waits until you send a turn or it reaches its expiry (expires_in_s, here 72 hours), so your review can
take as long as it needs within the run's life. During the private beta a suspended run is never evicted
to make room for others.
When the review finds problems, send them to the agent as a follow-up turn on the same run:
curl "https://api.forgeling.io/v1/runs/$RUN/turns" \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-42-review-2" \
-d '{
"input": [{"type": "text", "text": "Automatic review findings:\n…\nFix them and push to the same branch."}],
"output_schema": {
"type": "object",
"required": ["pr_url", "head_sha", "summary", "ui_visible"],
"properties": {
"pr_url": {"type": "string"},
"head_sha": {"type": "string"},
"summary": {"type": "string"},
"ui_visible": {"type": "boolean"}
}
},
"metadata": {"stage": "fix", "round": 2}
}'
Idempotency-Key is required here too, and the turn keeps it as its idempotency_key: if the answer
and the webhook are both lost, GET /v1/turns?idempotency_key= finds the turn.
A turn sent to a suspended run wakes it. The turn waits as turn.queued, with "reason": "resuming",
while Forgeling, in this order, finds capacity for the run, restarts its sandbox, refreshes expired
refreshable secrets through your credential-refresh URL and delivers the stored ones, checks the default
branch's protection again, and emits run.resumed; then the turn starts.
- Protection removed: the turn fails with
repo_policy_unmetand the run is torn down. - No capacity: if the resume cannot start within
resume_timeout_s(900 seconds by default), the turn fails withcapacity_unavailableand the run stays suspended. Send the turn again later. - Another turn running: a turn sent while one runs waits as
turn.queued, with"reason": "busy".
After each round, re-read the pull request and review its new head SHA. The run's budget caps the loop:
when it is spent, the turn ends with stop_reason budget_reached, and PATCH /v1/runs/{run} with a
higher "budget": {"max_cost_usd": …} lets it go on.
Previews
When the review passes, ask the agent for a preview that a person can judge without reading code: a short recorded video of the change, or a visual summary when a video is not possible. The preview step needs the approved tier.
The agent records against a preview your customer's project already deploys for the pull request, which
you can find from GitHub's deployments or checks. Pass its URL as trusted context on the turn. If the
preview needs a login, store it as run secrets first:
curl -X PUT "https://api.forgeling.io/v1/runs/$RUN/secrets/PREVIEW_USER" \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value": "demo@acme.example"}'
curl -X PUT "https://api.forgeling.io/v1/runs/$RUN/secrets/PREVIEW_PASSWORD" \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value": "…"}'
On a running or idle run a secret is delivered at once. On a suspended run it is stored and delivered when the run resumes, before the turn starts. Then send the preview turn:
curl "https://api.forgeling.io/v1/runs/$RUN/turns" \
-H "Authorization: Bearer $FORGELING_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-42-preview" \
-d '{
"input": [{"type": "text", "text": "Record a Playwright video of at most 90 seconds that walks through the acceptance criteria, to $RUN_OUTPUTS/preview.webm. Use the preview at the URL in context, and log in with $PREVIEW_USER and $PREVIEW_PASSWORD if asked. If there is no preview URL and the app in $RUN_REPO is self-contained, start it and record against localhost. Otherwise, or if the change is not visible in the UI, write a visual summary to $RUN_OUTPUTS/summary.md: a diagram of what changed, before-and-after descriptions, screenshots where possible, and the test evidence."}],
"context": {"preview_url": "https://pr-118.preview.acme.example", "head_sha": "<the reviewed SHA>"},
"output_schema": {
"type": "object",
"required": ["kind", "path"],
"properties": {"kind": {"enum": ["video", "summary"]}, "path": {"type": "string"}}
}
}'
The agent starts the app itself only when the repository is self-contained, with no external services: a
server started inside a run can be reached only from inside it, which is where the agent records it.
Without a usable preview, or for a change that is not visible, it writes the visual summary instead. The
turn's result says which it made, such as {"kind": "video", "path": "outputs/preview.webm"}, and
output.created brings the file. Download it, and re-host it in private storage of yours:
curl -L -o preview.webm "https://api.forgeling.io/v1/files/$FILE/content" \
-H "Authorization: Bearer $FORGELING_API_KEY"
Do not rely on IP allowlisting to let a run reach a preview. Forgeling publishes no egress IP addresses, and runs share their egress with other apps, so an address proves nothing about who is calling. Give the agent a login as run secrets instead, as above.
The run image has Playwright and Chromium preinstalled, and require('playwright') works from any
directory through NODE_PATH. A repository that pins its own Playwright version runs
npx playwright install chromium, which installs that version's Chromium into
$PLAYWRIGHT_BROWSERS_PATH on the run's disk.
Finish the run
When you have the preview, or whenever you no longer need the agent, complete the run:
curl -X POST "https://api.forgeling.io/v1/runs/$RUN/complete" \
-H "Authorization: Bearer $FORGELING_API_KEY"
Forgeling collects the outputs one last time, destroys the sandbox without keeping a backup and deletes
the run's secrets. run.completed carries usage_total and pushed_refs. A person can now look at the
preview and approve the change. If they ask for changes instead, send the run another turn if you have
not completed it yet, or start a new run on the same branch, which is then checked out, not created
again.
Merge only when the pull request's head equals the reviewed SHA. Re-read the pull request from GitHub,
check its repository and its base, check that its head SHA is the SHA you reviewed and previewed, and
merge with your GitHub App. The agent could not have pushed to the default branch, because the run would
not have started without a rule blocking direct pushes. On a repository an admin opted in with
allow_unprotected, also compare pushed_refs and the default branch's history with GitHub before you
merge, and label the work as built without push protection.
Every token of every turn was billed to the workspace's own Anthropic key. Each turn.completed reports
its usage with a cost_usd; the sandbox reports these figures, so treat them as information, not as a
bill. GET /v1/usage sums them by day or by run.