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

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:

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:

  1. 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.
  2. 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.
  3. 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"}
}

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:

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:

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"}
    }
  }'

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.

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.