SmolForge is a Git remote host for coding agents with built-in CI/CD Actions, full site hosting, Identity and AI services, repository agents, and agent transcript capture. Transcript and skill sharing and syncing are next. Base URL: https://forge.smol.ai Source: https://forge.smol.ai/swyx/forge Human docs: https://forge.smol.ai/docs CLI contract: https://forge.smol.ai/docs/reference/cli
This is Forge's canonical agent entry point. It documents the public API, authentication boundaries, component-scoped release authority, CLI, and transcript setup. Treat examples as least-privilege starting points; the server remains authoritative for scopes, limits, account capabilities, and Alpha availability.
Install the supported CLI and authenticate the current machine. Interactive login asks for a username or email before it asks for a password and stores the session through the platform credential store; it never prints the credential.
npm install -g @smolai/forge
sf auth login --username <username>
sf auth status
sf llms --save llms.txtUse sf auth login --email <email> instead when email is the intended identity.
For non-interactive automation, set exactly one of SMOLFORGE_USERNAME or
SMOLFORGE_EMAIL plus SMOLFORGE_PASSWORD; structured output never prompts.
Do not pass passwords or tokens as ordinary command-line arguments.
The CLI can describe its exact installed contract offline:
sf help
sf commands --json
sf schema --jsonsf auth login installs a Forge-only Git credential helper. It mints and
caches a one-day, repository-scoped credential only when Git accesses a Forge
remote; it does not change remotes or generic Git helpers. Verify normally:
sf auth login
git ls-remote origin HEADsf auth logout revokes helper-issued repository credentials, removes that
Forge-only helper, and deletes the local Forge session.
For automatic agent transcript capture in an existing checkout:
sf hooks install <owner>/<repo> --agent all --git post-commit,pre-push
sf hooks status <owner>/<repo>Forge resources are owned by a personal or organization account. Teams are
access groups inside organization accounts; they do not own service tiers,
quotas, or future billing. The resource-owning account determines the effective
free, pro, team, or enterprise tier.
Authenticated discovery:
GET /api/accounts GET /api/accounts/:accountId GET /api/accounts/:accountId/capabilities
Capabilities are useful for preflight, but the server remains authoritative. Repository configuration may disable capabilities or lower limits; it cannot raise account or platform ceilings. Accounts that become over quota retain read, export, deletion, and usage-reducing operations.
Public contract: https://forge.smol.ai/spec/accounts/v1 https://forge.smol.ai/spec/identity/v1
Hosted applications that request Forge Identity can use the framework-neutral account control from the platform browser client:
import { forge } from "/.forge/client.js"; forge.identity.mountAccountControl("#account");
The custom element emits forge-identity-change and forge-account-select.
Account selection is app-local context; it does not change resource ownership,
service tier, or the Forge control-plane account. Visitors can review disclosed
scopes and end active hosted-app sessions in Forge Settings.
Platform administrators can inspect the canonical account ledger at
/admin/accounts. The corresponding /api/admin/accounts endpoints expose
account search, tier catalog, effective entitlements, usage evidence,
side-effect-free change previews, and retry-safe append-only tier or override
writes. Every write requires an audit reason; ordinary users receive 403.
/admin/operations compares 7/30/90-day user and account growth, ranks
resource-owning accounts by retained bytes, and shows named request-pressure,
deployment-failure, and warning thresholds. Operators may append account
warnings or temporary personal-account request caps. Caps only lower an
existing fixed-window allowance, expire within 30 days, and can be cleared by
another append-only operation. Organization request caps are not supported
until traffic is attributable to a resource-owning account.
Forge user credentials have active, suspended, and tombstoned states.
Suspension invalidates password login, signed sessions, PATs, and Git
authentication through a user authorization generation. It also suspends the
personal account so account-generation fanout reaches hosted applications.
Tombstoning retains audit-safe identity and authorship rows while redacting
public identity fields; repository R2 and D1 data is removed first through the
normal asynchronous repository deletion coordinator.
Every Forge repository has its own durable, multi-turn agent authority. Instant is generally available: it reads a deterministic bounded evidence set at one exact source SHA and returns validated file citations. It cannot run a shell, execute tests, access the network or secrets, or publish changes.
Workspace Alpha is runnable only for explicitly allowlisted repository/user
pairs with repository write permission and agent:write. It executes pinned
OpenCode in a run-scoped Cloudflare Sandbox, receives no Forge PAT or provider
deployment credential, and cannot select its output ref. Forge independently
inspects the stopped filesystem, runs server-declared tests again in a fresh
trusted checkout, reconstructs the change from the accepted SHA, and publishes
one fenced agent/* branch plus one deduplicated draft Forge pull request.
Direct REST starts at:
POST /api/repos/:owner/:repo/agent/threads POST /api/repos/:owner/:repo/agent/threads/:thread_id/messages GET /api/repos/:owner/:repo/agent/threads/:thread_id GET /api/repos/:owner/:repo/agent/threads/:thread_id/events?after=:cursor
Forge assigns readable agent_thread_... handles when one is not supplied.
External automations should use a repository-scoped personal access token or an
agent service-principal key with agent:read and agent:message grants.
The same authority is available through stateless Streamable HTTP MCP:
POST /mcp/agents
Authorization: Bearer
Tools: forge_agent_message, forge_agent_get_thread,
forge_agent_list_threads, and forge_agent_list_events. The message tool
returns after durable acceptance; read the thread or cursorable events for
completion. MCP Tasks, OAuth discovery, resource templates, and change
subscriptions are not currently exposed.
Full guide: https://forge.smol.ai/docs/docs/repository-agent-v1
Forge AI is a Forge-owned, server-side model-routing capability. Applications
declare named profiles in app.ai.profiles inside forgeBuild.ts; they never
provide provider credentials, base URLs, raw provider model IDs, tenancy, or
billing-account identifiers.
Supported now:
forge/text-fast@1;env.forge.ai.responses.create(...);env.FORGE_AI.responsesCreate(...) RPC binding;Declare a connected server profile:
import { defineForge, importWrangler } from '@smolai/forge/config';
export default defineForge({ version: 1, app: { entrypoint: 'dist/server/index.js', ai: { profiles: { gameCommentary: { model: 'forge/text-fast@1', audience: 'server', funding: ['host'], maxInputTokens: 2500, maxOutputTokens: 180, response: { type: 'json_schema', name: 'game_commentary', schema: { type: 'object' }, }, limits: { requestsPerMinute: 10, hostCostMicrosPerDay: 1000000, previewHostCostMicrosPerDay: 50000, }, privacy: { contentLogging: 'off' }, }, } }, }, routes: [{ pattern: '/*', to: 'app.http' }], provider: { cloudflare: { wrangler: importWrangler('wrangler.toml', { shareProductionResources: true, }), }, }, });
Call it only from a Worker/server handler:
const result = await env.FORGE_AI.responsesCreate({
profile: 'gameCommentary',
input: [
{ role: 'system', content: 'Use only approved public facts.' },
{ role: 'user', content: JSON.stringify(approvedEvidence) },
],
billing: { source: 'host' },
idempotencyKey: commentary:${eventDigest},
});
Requests contain 1-64 system, user, or assistant messages and an 8-128
character idempotency key. The normalized result contains outputText, optional
outputJson, logical route, estimated usage/cost, and a policy/pricing receipt.
Coding agents must apply an application timeout, validate outputJson against
the expected product shape and allowed facts, and preserve a deterministic
fallback. Declared JSON Schema currently requests and parses JSON but Forge does
not yet enforce full schema conformance. Idempotency prevents a second charge;
it does not replay stored output in this Alpha.
Browser code calls an app-owned, narrow server endpoint. The app remains
responsible for body validation, authentication or Turnstile, abuse controls,
and output validation. Do not author a FORGE_AI Wrangler binding; Forge owns
and injects it into eligible connected Worker Versions.
Unavailable: BYOK, browser-direct Forge AI, streaming, tools, media, embeddings, provider selection, arbitrary models/URLs/headers, public HTTPS inference, and cross-account connected transport. Internal OpenAI, Gemini, and Featherless adapters exist, but connected applications currently always use Workers AI and cannot select those providers.
Full guide: https://forge.smol.ai/docs/docs/forge-ai-v1
Forge itself uses forge-release/v2. Feature tasks push immutable branch SHAs,
open ready pull requests, and submit them to the protected merge queue. They do
not push main, run provider deployment commands, or negotiate a release lane.
SHA="$(git rev-parse HEAD)"
sf release enqueue "$SHA" \
--ref codex/my-feature \
--repo swyx/forge \
--pr 123 \
--yes \
--json
sf release status fmq_request_id --repo swyx/forge --json
sf release watch fmq_request_id --repo swyx/forge --jsonAffected CI produces signed evidence bound to the exact feature SHA and target
base. The merge queue is the only normal authority that advances main. One
resumable controller owns managed Workers and connected edge under a monotonic
Durable Object fencing token. D1 stores the durable queue, plan, phase history,
and provider receipts; it is not the runtime mutex.
If exact-candidate CI for the same base is already running, the queue joins it; it does not launch a duplicate validation run. Managed Workers activate in dependency-safe parallel waves. Cancellation is terminal and resumable. A signed plan containing a new component bootstraps deploy-control under the existing fence, then resumes with the exact-source manifest.
Impact is component-scoped. The controller compares each component input-tree digest with its last successful activation receipt. Unchanged components may legitimately run older source SHAs. Provider source SHA is provenance, not an impact baseline, so release completion must never require every Worker to match the edge SHA.
The supported observation contract is sf release status/watch and schema
forge.release.status.v2.
Direct Wrangler and local component deployment are audited break-glass recovery only.
Runtime and data ownership are machine-readable. Identity, content-control,
repository, deploy-control, and notifications have separate workspace entrypoints and
Wrangler contracts. packages/api is an internal implementation kernel during
the source move, not a deployment unit. config/forge-database-domains.json
assigns every canonical cloudforge-next table to a future domain D1 target;
npm run test:database-domains fails on missing or overlapping claims and
reports cross-domain foreign keys. Domain databases are additive shadow
targets until a Time Travel bookmark, deterministic replay, schema/row/content
parity, owner-local transactions, and rollback binding are all proven. The
shared production database is never recreated as part of a normal release.
Every runtime owns its required health bindings in its own workspace
reliability.ts; there is no global binding registry that makes a new service
an all-Workers release.
Normative references:
Cloudflare provides Forge's runtime, storage, networking, and state primitives. Forge Deploy adds a Git-native release control plane above them:
Repository build code receives no provider deployment credentials. Connected Cloudflare applications can retain existing bindings and their production domain while adopting Forge's immutable preview and activation workflow. Deliberate manual promotion or rollback remains possible and is recorded as an explicit administrator action.
Forge applies a stricter form of this model to its own API and web application. The fenced release controller executes only affected managed Workers and creates an immutable connected-edge candidate when the edge input digest changes. Provider activation is serialized, checks the expected active Worker version immediately before switching traffic, and records the resulting Cloudflare deployment receipt.
Cloudflare does not issue standalone version-preview URLs for Workers that implement Durable Objects. Forge stages those immutable candidates at zero percent beside the exact current 100 percent production version, then selects the candidate only through the Forge preview proxy with Cloudflare's version-override header. Ordinary traffic remains on production until the fenced controller promotes the verified candidate.
The Forge control-plane hostname remains directly attached to the API Worker; it is not routed through the user-content Sites edge. Wrangler administrator access remains the independent recovery path. Agents must not describe this as Forge replacing Cloudflare: Forge supplies the release control plane, while Cloudflare remains the runtime and provider-level rollback surface. After Wrangler recovery, a new Forge candidate freezes the observed provider version as its activation base. Promotion compares against that exact base, so Forge can resume normal release control without overwriting a newer unobserved administrator deployment.
Public guide: https://forge.smol.ai/spec/deploy/v1#why-forge
For an enrolled repository, Forge may automatically build each open same-repository pull request at its exact head SHA. Configure the policy from the repository's Sites tab. The pull request shows the frozen commit, workflow logs, an immutable deployment URL, and a stable PR URL:
preview-pr-
The stable URL advances only when a successful deployment still matches the pull request's desired generation, exact head SHA, and current policy generation. Late builds remain immutable but cannot move the alias backwards. Closing or merging the pull request revokes the stable alias immediately.
Initial automatic previews are public-unlisted, receive no secrets, use deployment-isolated state and preview quotas, deny runtime egress, and cannot publish production. Fork pull requests and connected Cloudflare deployments that share production resources are blocked.
API:
GET /api/repos/:owner/:repo/pulls/:number/preview POST /api/repos/:owner/:repo/pulls/:number/preview/retry GET /api/repos/:owner/:repo/sites/pr-previews PUT /api/repos/:owner/:repo/sites/pr-previews
Public guide: https://forge.smol.ai/docs/docs/deploy/pull-request-previews
Measured Git hosting releases, technical decisions, and their production proof are published separately from the operating documentation:
https://forge.smol.ai/blog https://forge.smol.ai/blog/feed.xml https://forge.smol.ai/blog/the-great-agent-runtime-bakeoff
Current field notes:
https://forge.smol.ai/blog/moving-523-routes-off-vercel-to-forge https://forge.smol.ai/blog/layered-source-checkpoints-for-large-repositories https://forge.smol.ai/blog/every-repository-gets-its-own-agent https://forge.smol.ai/blog/from-two-minute-checkouts-to-fourteen-seconds https://forge.smol.ai/blog/making-large-git-pushes-ordinary
The blog explains why a capability exists and what Forge learned while shipping it. The documentation and public specifications remain the authoritative contracts for supported behavior, limits, and APIs.
For every new JavaScript or TypeScript project, Forge strongly recommends pnpm as the default package manager. Bun is the preferred high-speed option when the project's dependencies, lifecycle scripts, and runtime requirements are compatible. npm and Yarn remain supported for existing repositories, but agents should not choose them for a new project unless the user or an existing project constraint requires it.
Declare the choice in package.json with the packageManager field and commit
exactly one matching lockfile:
pnpm-lock.yaml (recommended default)bun.lock (high-speed option)package-lock.json or npm-shrinkwrap.jsonyarn.lockWhen onboarding an existing repository, preserve its current package manager. When starting an unconfigured JS/TS repository, choose pnpm unless Bun was explicitly selected. Remove stale lockfiles after a migration; conflicting lockfiles are treated as an ambiguous configuration and trigger guidance in Forge's UI and CLI.
Forge Deploy requires a root-level forgeBuild.ts, but humans and agents do
not need to author it from scratch. After changing the Git remote and pushing,
run the migration assistant locally or open the repository's Sites tab:
git remote set-url origin https://forge.smol.ai/<owner>/<repo>.git
git push -u origin "$(git branch --show-current)"
sf migrateFor multi-repository GitHub migrations, sf repo import github <org-or-user>
streams ordinary Git receive-pack requests through bounded concurrent
multipart staging into Forge's restricted native-Git runner. Forge stores
immutable objects before moving refs, and keeps interrupted imports safe to
retry. There is no GitHub repository-size estimate
cutoff. Each individual push is bounded at 500 MiB; larger histories can use
incremental pushes.
The GitHub history importer preserves standard Git LFS pointer files but does
not copy GitHub-hosted LFS object bytes. Upload those bytes separately with a
standard client or Forge's authorized LFS HTTP endpoints. Exact-SHA builds of
already-managed pointers and the Sites asset adoption API do not require a
local git-lfs binary. The adoption API is implemented and tested in source,
but is not live until a separate Forge release. See /spec/assets/v1.
OpenNext can keep selected unchanged public LFS binaries deferred through the
build. Forge plans their provider identity from the verified content digest
and extension, then checksum-fences and base64-streams only the hashes the
connected provider requests. Aggregate release capacity is entitlement-driven
up to 1 GiB while Cloudflare's 25 MiB per-file and 100,000-file ceilings remain.
Connected Worker versions receive a reserved Forge-managed FORGE_SOURCE_SHA
plain-text binding with the exact 40-hex release commit; repository Wrangler
configuration cannot override it.
Repository-administrator asset adoption API:
GET /api/repos/:owner/:repo/sites/migration/assets?sha=<40-hex-source-sha> POST /api/repos/:owner/:repo/sites/migration/assets
GET is read-only and returns the primary branch, exact source/head SHA, fixed
v1 suggestion policy, existing config, literal entries, and each object's Git
blob SHA, SHA-256 OID, size, and upload status. Upload objects reported with
uploaded: false through the existing standard Forge LFS Batch/basic HTTP API,
then inspect again. POST requires version 1, an idempotency key, matching exact
source/expected-head SHA, the primary target branch, reviewed entries, and the
exact object list. It revalidates byte identity and creates a derived migration
branch, commit, pull request, and immutable receipt. The migration PR's Deploy
preview is suppressed. This does not merge the PR, advance production, deploy,
or change DNS.
sf migrate statically inspects repository files, previews a complete typed
configuration, and explains compatibility findings. It does not execute
repository code. sf migrate --json is the non-interactive, read-only form.
For a repository containing both an HTTP Worker and assets, use
--target http or --target assets to make the same explicit choice offered
by the browser wizard.
--write creates a missing forgeBuild.ts; replacing one additionally requires
--force. --commit creates a local commit but never pushes it.
The Sites migration screen uses the same rules against an exact Forge commit. Previewing never writes. Server-side write and overwrite actions create an expected-head-protected Git commit, and overwrite also verifies the identity of the existing configuration.
For pnpm v9 monorepos, exact-source builds use the frozen lockfile importer
index to discover the selected project and transitive declared workspace:*
dependencies without walking unrelated sibling workspaces. Unsupported
lockfile syntax falls back to conservative full-tree discovery.
Primary branch selection treats main and master equivalently when only one
exists. When both exist, main wins and Forge returns a warning because two
primary-looking branches are a likely source of confusion. Otherwise Forge
preserves a valid configured default branch.
Detection is not a compatibility promise. Existing Cloudflare bindings, Durable Objects, secrets, scheduled jobs, service bindings, or unsupported compatibility flags are reported as findings. Changing the Git remote does not change an existing Cloudflare deployment, storage, DNS, or custom domain.
For migration evaluation, Forge can proxy compiled app.http routes to a
connected Cloudflare Worker deployment through the deployment's unlisted Forge
preview hostname. The trusted publisher must record a root HTTPS
*.workers.dev version-preview origin; credentials, paths, query strings,
fragments, nonstandard ports, and lookalike domains are rejected. Forge
rechecks deployment eligibility on every request, strips spoofed internal
request headers, sanitizes provider response headers and parent-domain cookies,
and applies no-store, baseline security headers, noindex, and the Forge
deployment receipt.
Declare this path with
importWrangler("wrangler.toml", { shareProductionResources: true }) inside
provider.cloudflare.wrangler in forgeBuild.ts. Forge validates the checked-in
TOML as inert exact-commit data. The acknowledgement is required because the
preview inherits the connected Worker's D1, KV, R2, Durable Object, secret, and
service bindings and can read or mutate the same production resources.
This is a compatibility preview, not a confidentiality boundary or domain cutover. The provider URL may remain directly reachable if discovered, and direct requests bypass Forge access checks. Existing production routes, DNS, storage, and custom domains remain unchanged until an administrator separately graduates the domain.
Public guide: https://forge.smol.ai/spec/deploy/v1#migrate
Browser login authenticates the web app only. Use the Forge CLI to configure local Git access; do not copy browser credentials into a terminal.
You are working with SmolForge at https://forge.smol.ai.
If a Chrome/browser tab is already signed in, use that existing browser session;
do not ask for the account password and do not sign the user out. Confirm the
active Forge account from the account menu or https://forge.smol.ai/settings
before making any write. Browser authentication is stored in that browser's
site storage, not in a cookie or the terminal.
For local Git work, install `@smolai/forge` and run `sf auth login`. It configures
a Forge-only Git credential helper. Git automatically receives a short-lived,
repository-scoped credential for each Forge remote; never put a token in a
remote URL. Verify with `git ls-remote origin HEAD`.
Do not copy, print, commit, or paste the browser session JWT into chat, shell
history, source code, a remote URL, or a transcript. Do not inspect browser
storage to obtain it; use the signed-in browser UI to create the scoped PAT.
If the browser session is unavailable or belongs to the wrong account, stop and
ask the user to sign in or switch accounts. Do not try to recover credentials
from the browser profile, keychain, or another application's storage.This lets a browser-capable agent reuse an already authenticated web session for Forge UI/API work. The browser session token is deliberately not a Git credential and must remain in browser storage.
The supported local Git path is the SmolForge CLI. Login installs a Forge-only
credential helper and keeps credential.useHttpPath enabled. When Git accesses
https://forge.smol.ai/<owner>/<repo>.git, the helper securely mints or reuses
a one-day, repository-scoped credential in the OS keychain. sf auth logout
revokes every credential it created before removing the helper and CLI session.
npm install -g @smolai/forge
sf auth login --username <username>
git ls-remote origin HEAD
git push origin "$(git branch --show-current)"sf auth login can read the password from stdin when no TTY is available. For
automation, SMOLFORGE_USERNAME and SMOLFORGE_PASSWORD are also accepted.
The installed helper invokes the exact CLI executable that performed login, so
Git remains authenticated across FNM/Node version switches. It does not modify
GitHub or other Git hosts, generic credential helpers, local remotes, or source
files. Read-only collaborators receive a read-scoped credential and cannot push.
Authenticated API requests use either a login-session JWT or a Personal Access Token as a Bearer token. Token-management endpoints require a login-session JWT. PAT access is limited by its scopes and optional repository restriction.
Authorization: Bearer <token>POST /api/auth/login
Content-Type: application/json
{ "username": "...", "password": "..." }
→ { "user": { "id", "username", "email" }, "token": "eyJ..." }Use { "email": "...", "password": "..." } instead to sign in by email.
GET /api/auth/login-options returns the enabled sign-in controls and, once
multiple methods are available, a privacy-thresholded aggregate recommendation.
It never returns individual login activity or method counts.
POST /api/auth/register
Content-Type: application/json
{ "username": "...", "email": "...", "password": "..." }
→ { "user": {...}, "token": "eyJ..." }GET /api/auth/me
Authorization: Bearer <token>
→ { "user": {...} }GET /api/auth/whoami
Authorization: Bearer <token>
→ { "authenticated": true, "auth_type": "session"|"personal_access_token", "user": {...} }Supported scopes are repo:read, repo:write, transcripts:write,
actions:read, actions:write, gist:read, gist:write, wiki:ask,
agent:read, agent:message, agent:run, agent:cancel, agent:approve,
agent:write, and reserved agent:configure. Supported expirations are 1h,
1d, 30d, and never. Token values are shown once in the creation response.
Request only the scopes required for the current repository and operation.
POST /api/auth/tokens
Authorization: Bearer <session-token>
{ "name": "agent", "scopes": ["repo:read", "repo:write"], "expires_in": "30d", "repository?": "owner/repo" }
→ { "token": "forge_pat_...", "personal_access_token": {...metadata...} }GET /api/auth/tokens
Authorization: Bearer <session-token>
→ { "personal_access_tokens": [...metadata] }DELETE /api/auth/tokens/:id
Authorization: Bearer <session-token>
→ 204Gists are bounded text snippets with up to 10 files. Public gists appear in discovery; unlisted gists are omitted from discovery but remain readable by anyone who has the opaque URL.
GET /api/gists
Auth: optional (`gist:read` PAT scope)
Query: `page` (default 1), `per_page` (default 20, maximum 50), optional
`owner=<username>` for one owner's public gists, optional `q=<text>` to search
descriptions and filenames (maximum 64 characters)
→ {
"gists": [{ "id", "description", "visibility", "file_count", "first_filename", "preview", "owner", ... }],
"pagination": { "page": 1, "per_page": 20 }
}
GET /api/gists?mine=1
Auth: required
Also accepts `page` and `per_page`.
→ {
"gists": [public and unlisted gists owned by the current user],
"pagination": { "page": 1, "per_page": 20 }
}
POST /api/gists
Auth: required (`gist:write` PAT scope)
{
"description": "optional",
"visibility": "public"|"unlisted",
"files": [{ "filename": "snippet.ts", "content": "export {}" }]
}
→ 201 { "gist": { ..., "files": [...] } }
GET /api/gists/:id
Auth: optional
→ { "gist": { ..., "files": [...] } }
GET /api/gists/:id/raw/:filename
Auth: optional
→ text/plain
GET /api/gists/:id/revisions
Auth: optional
Query: `page`, `per_page` (maximum 50)
→ { "revisions": [{ "id", "revision_number", "description", "visibility", "file_count", "created_at", ... }], "pagination": ... }
GET /api/gists/:id/revisions/:revisionId
Auth: optional
→ { "revision": { ..., "files": [{ "filename", "content", "position" }] } }
PUT /api/gists/:id/star
DELETE /api/gists/:id/star
Auth: required (`gist:write` PAT scope)
→ { "star_count", "fork_count", "viewer_has_star" }
POST /api/gists/:id/forks
Auth: required (`gist:write` PAT scope)
{ "revision_id": "exact immutable revision id" }
→ 201 { "gist": { ..., "forked_from": { "gist_id", "revision_id" } } }
PATCH /api/gists/:id
Auth: required, owner only (`gist:write` PAT scope)
{ "description", "visibility", "files" }
→ { "gist": { ..., "files": [...] } }
DELETE /api/gists/:id
Auth: required, owner only (`gist:write` PAT scope)
→ 204Each filename is a root-level name, not a path. A file may contain at most 256 KiB of UTF-8 text and one gist may contain at most 1 MiB in total. Each Gist retains at most 100 immutable revisions, each account retains at most 100 MiB of revision history, and search is derived metadata rather than authoritative content.
POST /api/repos
Auth: required
{ "name": "my-repo", "description": "optional", "visibility": "public"|"unlisted"|"private", "license_spdx?": "MIT" }
→ { "repository": { "id", "name", "description", "visibility", "license_spdx", "default_branch", "created_at" } }
`visibility` is the sole repository visibility contract. `unlisted` repositories
are readable by direct URL but are omitted from public profiles and discovery.GET /api/repos
Auth: required
→ { "repositories": [...] }GET /api/repos/:owner/:repo
Auth: optional (private repos require auth)
→ { "repository": { ..., "owner": { "username" }, "star_count": 0, "fork_count": 0, "viewer_permission", "is_owner", "can_fork", "parent" } }PATCH /api/repos/:owner/:repo
Auth: required (user owner or organization owner/admin)
{ "name?", "description?", "visibility?", "license_spdx?", "default_branch?" }
→ { "repository": {...}, "newUrlPath?": "/newowner/newname" }POST /api/repos/:owner/:repo/forks
Auth: required; viewer must be able to read the source and cannot own it
{ "name?": "destination-name", "visibility?": "public"|"unlisted"|"private" }
→ 202 { "repository": {...}, "url": "/viewer/destination-name", "job": {...}, "status_url": "/api/repository-jobs/:id" }Forks receive an independent copy of Git objects and refs plus source ancestry and license metadata. Issues, collaborators, secrets, workflows, webhooks, and transcripts are not copied. Destination visibility defaults to the source visibility; a private source can only be forked as private.
Forking runs as a durable background job with object/byte progress, cancellation, three attempts, and cleanup. The destination stays private until the independent copy is complete. Forks are limited to 5 GiB of stored Git objects.
GET /api/repository-jobs/:id
Auth: required (job requester)
→ { "job": { "status": "queued"|"running"|"succeeded"|"failed"|"cancelled", "progress": 0..100, ... } }
POST /api/repository-jobs/:id/cancel
Auth: required (job requester)
→ 202 { "job": {...} }GET /api/licenses
Auth: not required
→ { "licenses": [{ "spdx_id", "name", "description", "url" }] }PUT /api/repos/:owner/:repo/license
Auth: required (repository admin)
{ "license_spdx": "MIT"|"Apache-2.0"|"AGPL-3.0-or-later" }
→ 201 { "repository": {...}, "license_file_created": true, "commit": "...", "file": { "path": "LICENSE", "sha": "...", "size": 1077 } }The license endpoint creates or updates LICENSE with canonical SPDX text and
advances the default branch with a real Git commit.
DELETE /api/repos/:owner/:repo
Auth: required (owner only)
→ 202 { "job": {...}, "status_url": "/api/repository-jobs/:id" }PUT /api/repos/:owner/:repo/star → { "starred": true }
DELETE /api/repos/:owner/:repo/star → 204
GET /api/repos/:owner/:repo/star → { "starred": bool }
GET /api/repos/:owner/:repo/stargazers?limit=30&offset=0 → { "stargazers": [...], "count": N }GET /api/repos/:owner/:repo/contents?ref=main
Auth: optional
→ { "type": "dir", "entries": [{ "type": "file"|"dir", "name", "path", "sha" }] }GET /api/repos/:owner/:repo/contents/:path?ref=main
Auth: optional
→ file: { "type": "file", "name", "path", "content": "<base64>", "sha", "size" }
→ dir: { "type": "dir", "entries": [...] }PUT /api/repos/:owner/:repo/contents/:path
Auth: required
{ "content": "raw text", "message": "commit msg", "branch": "main", "sha?": "old-sha", "new_branch?": "patch-1" }
→ { "commit": "sha", "branch": "main", "file": {...}, "author": "username", "message": "..." }GET /api/repos/:owner/:repo/readme?ref=main
→ { "type": "file", "name": "README.md", "content": "raw text", ... }GET /api/repos/:owner/:repo/last-commits/:path?ref=main
→ { "entries": { "filename": { "sha", "message", "date", "author_name" } } }GET /api/repos/:owner/:repo/commits?ref=main&page=1&per_page=30
Auth: optional
→ { "commits": [{ "sha", "message", "author": { "name", "email", "date" }, "parents": [...] }], "pagination": {...} }GET /api/repos/:owner/:repo/commits/:sha
Auth: optional
→ { "sha", "message", "author", "committer", "parents", "tree" }GET /api/repos/:owner/:repo/branches
→ { "branches": [{ "name", "sha", "is_default" }] }POST /api/repos/:owner/:repo/branches
Auth: required (write permission)
{ "name": "feature-x", "sha": "base-commit-sha" }
→ { "name", "sha", "is_default" }DELETE /api/repos/:owner/:repo/branches/:branch
Auth: required (write permission, cannot delete default)
→ 204GET /api/repos/:owner/:repo/issues?state=open&page=1&per_page=30
→ { "issues": [{ "number", "title", "body", "state", "author": {...}, "created_at" }], "pagination": {...} }POST /api/repos/:owner/:repo/issues
Auth: required
{ "title": "Bug report", "body?": "Details..." }
→ { "issue": {...} }GET /api/repos/:owner/:repo/issues/:number
→ { "issue": {...}, "comments": [...] }PATCH /api/repos/:owner/:repo/issues/:number
Auth: required
{ "title?", "body?", "state?": "open"|"closed" }
→ { "issue": {...} }POST /api/repos/:owner/:repo/issues/:number/comments
Auth: required
{ "body": "My comment" }
→ { "comment": {...} }GET /api/repos/:owner/:repo/labels → { "labels": [...] }
POST /api/repos/:owner/:repo/labels → { "label": {...} }
{ "name": "bug", "color?": "ff0000", "description?" }
POST /api/repos/:owner/:repo/issues/:number/labels → { "labels": [...] }
{ "labels": ["bug", "help wanted"] }
PUT /api/repos/:owner/:repo/issues/:number/labels → { "labels": [...] }
{ "labels": ["bug"] } // replaces allGET /api/repos/:owner/:repo/pulls?state=open&page=1&per_page=30
→ { "pull_requests": [...], "pagination": {...} }POST /api/repos/:owner/:repo/pulls
Auth: required
{ "title", "body?", "source_branch": "feature", "target_branch": "main" }
→ { "pull_request": {...} }GET /api/repos/:owner/:repo/pulls/compare?base=main&head=feature
→ {
"base": { "ref": "main", "sha": "..." },
"head": { "ref": "feature", "sha": "..." },
"files": [...],
"commits": [...],
"merge_status": {...}
}GET /api/repos/:owner/:repo/pulls/:number
→ { "pull_request": { "number", "title", "body", "state", "source_branch", "target_branch", "author": {...} } }GET /api/repos/:owner/:repo/pulls/:number/diff
→ { "files": [{ "filename", "status", "additions", "deletions", "hunks": [...] }] }GET /api/repos/:owner/:repo/pulls/:number/commits
→ { "commits": [...] }GET /api/repos/:owner/:repo/pulls/:number/merge-status
→ {
"head_sha", "base_sha", "mergeable": bool,
"reason": null|"no_changes"|"conflicts"|"diverged"|"unrelated_history",
"conflicts": [{ "path", "type" }], "merge_base_sha", "ahead": N, "behind": N
}PUT /api/repos/:owner/:repo/pulls/:number/merge
Auth: write or admin permission required
{ "expected_head_sha": "<head_sha from merge-status>" }
→ { "merged": true, "method": "fast-forward", "merge_commit_sha": "<real head SHA>", "pull_request": {...} }
→ 409 when the head/base is stale or the histories cannot be fast-forwarded
SmolForge currently supports fast-forward merges only. Diverged histories must
be reconciled locally before retrying; merge commits, squash, and rebase are not
yet supported.Use cache: auto for a pnpm, Bun, or npm container job. Forge detects the
declared packageManager field or a sole matching lockfile and restores only
that manager's store/download cache. pnpm and Bun are pinned into the standard
container image; use their frozen-lockfile installs directly. Do not add npm to
a pnpm or Bun repository simply for caching. Yarn remains supported as a
package manager but is intentionally not cached by auto yet; Forge skips an
ambiguous or unsupported configuration safely.
Generated Deploy and Sites builds use this manager-aware mode automatically. For npm, Forge may also restore an exact dependency snapshot keyed by the immutable source SHA and normalized project root; pnpm and Bun continue through their frozen store-backed installs.
Container workflows can opt into cross-build caches on each job:
jobs:
build:
runner: container
cache: auto
steps:
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: npm run build
- name: Test
run: npm run test:unitForge Deploy publishes an exact public-repository commit to
{project}.sites.smol.ai. It supports immutable assets, one HTTP application
handler, Forge-managed durable state, bounded managed realtime, immutable
previews, automatic or manual production publication, and pointer-based
rollback. The current preview is operator-allowlisted.
Install the public authoring package and add forgeBuild.ts at the repository
root:
npm install --save-dev @smolai/forgeimport { defineForge } from '@smolai/forge/config';
export default defineForge({
version: 1,
name: 'my-app',
app: {
entrypoint: 'src/server.ts',
assets: { directory: 'dist', fallback: 'index.html' },
},
routes: [
{ pattern: '/api/*', to: 'app.http' },
{ pattern: '/*', to: 'app.assets' },
],
});Validate locally, then deploy through ordinary authorized Git:
npx smolforge deploy check
git push origin mainForge evaluates forgeBuild.ts as static data without executing repository
code. forge.yml and forge.yaml are rejected. Preview URLs are
unauthenticated, unlisted, and noindex; their random hostname is not a
confidentiality boundary.
Application entrypoints default-export { async fetch(request, env) {} }.
Use env.forge.state.scope(name) for get, list, snapshot, and atomic
mutate. Use state-bound emit, env.forge.realtime.authorize, and
env.forge.realtime.snapshotResponse for managed realtime. Runtime egress is
denied by default.
Public references:
Repository administrators can configure and inspect Sites:
GET /api/repos/:owner/:repo/actions/release-status
→ {
"default_head_sha": "<40-hex>"|null,
"state": "not_configured"|"deploying"|"needs_attention"|"production"|"out_of_date",
"projects": [{
"project_root": ".",
"head_sha": "<40-hex>"|null,
"latest": { "source_sha", "state", "release_id", "workflow_run_id", ... }|null,
"production": { "source_sha", "state", "release_id", "hostname", ... }|null,
"synchronized": true|false
}]
}
GET /api/repos/:owner/:repo/sites
→ { "site": {...}|null, "production_url": "https://..."|null }
PUT /api/repos/:owner/:repo/sites
{
"slug": "stable-dns-label",
"production_branch": "main",
"build_command": "pnpm build",
"output_dir": "dist",
"spa_fallback": false
}
→ { "site": {...}, "production_url": "https://..."|null }
GET /api/repos/:owner/:repo/sites/deployments
→ { "deployments": [{ "source_sha", "status", "preview_url", "workflow_run_id", ... }] }Use actions/release-status before deciding whether a push is building,
blocked, or in production. It is the shared Actions/Sites read model:
historical Action failures are history, not a current release backlog. A
successful ordinary Action means the workflow passed; it does not by itself
mean a release exists or production moved. production.state: "production"
proves the authoritative Forge pointer. It is not an HTTP health claim unless
production.verification records one; verify the returned hostname separately
when live behavior matters.
The site slug is permanent after creation. Initial enablement, re-enablement, and suspension are platform-operator actions. Repository administrators may disable, redeploy, or promote a successful immutable deployment:
PATCH /api/repos/:owner/:repo/sites/state
{ "state": "disabled" }
POST /api/repos/:owner/:repo/sites/deployments/:deploymentId/redeploy
POST /api/repos/:owner/:repo/sites/deployments/:deploymentId/promoteDo not treat a successful build as proof that production moved. Compare the
deployment SHA with site.active_deployment_id, and verify both the immutable
preview URL and production URL when present. Private, unlisted, deleted, or
suspended source repositories are not served.
auto resolves to the matching pnpm, Bun, or npm cache and its fingerprint
includes the manager/version, lockfile, package metadata, platform, runner
image, and normalized Deploy project root. Monorepo projects therefore warm
independently even when they share a root lockfile. Generated npm Deploy jobs
may additionally restore an exact node_modules snapshot; npm-exact remains
an explicit npm-only mode for authored workflows. Cache misses and restore/save
errors are non-fatal. Only trusted pushes to the current default-branch SHA can
write caches; pull requests are read-only cache consumers.
Project commands must honor Forge's cache environment. When using the legacy
npm-exact mode and FORGE_EXACT_CACHE_HIT=1, skip npm ci because the exact
node_modules tree is already restored. FORGE_DEPENDENCY_CACHE_HIT=1
indicates that the manager-aware cache was restored. Use NPM_CONFIG_CACHE,
PNPM_STORE_DIR, BUN_INSTALL_CACHE_DIR, and ESLINT_CACHE_LOCATION rather
than hard-coding cache paths. Keep build and unit tests as separate commands
so a workflow can build once.
GET /api/repos/:owner/:repo/actions/workflows → { "workflows": [...] }
GET /api/repos/:owner/:repo/actions/workflows/:filename → { "workflow": {...} }
PUT /api/repos/:owner/:repo/actions/workflows/:filename → { "workflow": {...} }
{ "content": "name: CI\non:\n push:\njobs:..." }
DELETE /api/repos/:owner/:repo/actions/workflows/:filename → 204GET /api/repos/:owner/:repo/actions/runs?status=&branch=&limit=50&offset=0
→ { "runs": [...], "total", "limit", "offset" }POST /api/repos/:owner/:repo/actions/runs
Auth: required (write permission)
{ "workflow": "ci.yml" | "workflow_id": 1, "branch?": "main" }
→ { "run": {...} }GET /api/repos/:owner/:repo/actions/runs/:runId
→ { "run": { ..., "jobs": [...] } }POST /api/repos/:owner/:repo/actions/runs/:runId/cancel → { "message": "Workflow cancelled" }
POST /api/repos/:owner/:repo/actions/runs/:runId/rerun → { "run": {...} }GET /api/repos/:owner/:repo/actions/runs/:runId/jobs
→ { "jobs": [{ "id", "name", "status", "conclusion", "runner_type", "cache_mode", "started_at", "completed_at", "steps": [...] }] }GET /api/repos/:owner/:repo/actions/runs/:runId/jobs/:jobId/logs
→ { "logs": [{ "step_number", "step_name": "...", "lines": [{ "line_number", "content", "timestamp", "level" }] }] }GET /api/repos/:owner/:repo/actions/runs/:runId/artifacts → { "artifacts": [...] }
GET /api/repos/:owner/:repo/actions/secrets → { "secrets": [{ "id", "name", "created_at" }] }
PUT /api/repos/:owner/:repo/actions/secrets/:name { "value": "..." } → { "secret": {...} }
DELETE /api/repos/:owner/:repo/actions/secrets/:name → 204SmolForge stores AI coding agent conversations (Claude Code, Codex, Copilot, etc.) alongside commits. This is the core integration point for agents.
POST /api/repos/:owner/:repo/transcripts
Auth: required
Content-Type: application/json
{
"session_id": "uuid-of-session",
"agent_type": "claude-code" | "claude-cowork" | "codex" | "copilot" | string,
"commit_sha": "abc123...", // optional, links transcript to a specific commit
"messages": [
{
"role": "user" | "assistant" | "system",
"content": "the message text",
"timestamp": "2026-03-29T08:00:00Z", // ISO8601
"image_keys": ["key1", "key2"] // optional, from image upload
}
]
}
→ 201 {
"id": 1,
"repo_id": "...",
"session_id": "...",
"agent_type": "claude-code",
"message_count": 42,
"commit_sha": "abc123...",
"created_at": "..."
}Secret masking is applied automatically on storage. Patterns: cfut_* (Cloudflare), sk-* (OpenAI), ghp_* (GitHub), AKIA* (AWS), plus any repo-specific patterns.
GET /api/repos/:owner/:repo/transcripts?page=1&limit=20
Auth: required
→ { "transcripts": [...], "pagination": { "page", "limit", "total", "pages" } }GET /api/repos/:owner/:repo/transcripts/:sessionId?full=true
Auth: required
→ { "id", "session_id", "agent_type", "commit_sha", "messages": [...] }
Without ?full=true, assistant messages are truncated to 500 chars.GET /api/repos/:owner/:repo/commits/:sha/transcript?full=true
Auth: required
→ same as abovePOST /api/repos/:owner/:repo/transcripts/:sessionId/images
Auth: required
Content-Type: multipart/form-data
Body: file=@screenshot.png
→ { "key": "transcripts/repo-id/session-id/timestamp-random-filename.png", "url": "/api/repos/..." }GET /api/repos/:owner/:repo/transcripts/images/:key
Auth: required
→ binary image data with Cache-Control: public, immutable, max-age=31536000Secret masks define regex patterns that are automatically redacted in transcript content.
GET /api/repos/:owner/:repo/secret-masks
Auth: required
→ { "masks": [{ "id", "repo_id", "pattern", "label", "is_global", "created_at" }] }POST /api/repos/:owner/:repo/secret-masks
Auth: required (write permission)
{ "pattern": "my_secret_[A-Za-z0-9]+", "label": "Internal API Key" }
→ { "id", "repo_id", "pattern", "label", "is_global": 0, "created_at" }DELETE /api/repos/:owner/:repo/secret-masks/:id
Auth: required (write permission, cannot delete global masks)
→ { "success": true }GET /api/secret-masks/global
Auth: none
→ { "masks": [...] }Default global masks: cfut_[A-Za-z0-9]+, sk-[A-Za-z0-9-]+, ghp_[A-Za-z0-9]+, AKIA[A-Z0-9]{16}
POST /api/repos/:owner/:repo/hooks
Auth: required (owner only)
{ "url": "https://example.com/webhook", "secret?": "hmac-key", "events?": "push,issues" }
→ { "webhook": { "id", "url", "events", "active", "created_at" } }
Events: push, pull_request, issues, issue_comment, create, delete
Default: "push"GET /api/repos/:owner/:repo/hooks → { "webhooks": [...] }
GET /api/repos/:owner/:repo/hooks/:id → { "webhook": {...} }
PATCH /api/repos/:owner/:repo/hooks/:id { "url?", "secret?", "events?", "active?" }
DELETE /api/repos/:owner/:repo/hooks/:id → { "success": true }GET /api/repos/:owner/:repo/hooks/:id/deliveries?limit=50
→ { "deliveries": [{ "id", "event", "payload", "response_status", "success", "delivered_at", "duration_ms" }] }
POST /api/repos/:owner/:repo/hooks/:id/deliveries/:deliveryId/redeliver
→ { "delivery": {...} }Webhook payloads are signed with HMAC-SHA256 if a secret is set. Headers:
X-SmolForge-Event: event nameX-SmolForge-Delivery: unique delivery UUIDX-SmolForge-Signature: sha256=<hex> (only if secret is configured)POST /api/repos/:owner/:repo/scan/text { "text": "..." } → { "scan_result": { "safe": bool, "reasons?": [...] } }
POST /api/repos/:owner/:repo/scan/binary (raw body) → { "scan_result": { "safe": bool } }
POST /api/repos/:owner/:repo/scan/image (raw body) → { "scan_result": { "safe": bool } }POST /api/repos/:owner/:repo/reports
Auth: required
{ "content_type": "blob"|"issue"|"comment"|"pull_request", "content_ref": "sha-or-id", "reason": "..." }
→ { "report": {...} }
GET /api/repos/:owner/:repo/reports?status=pending
PATCH /api/repos/:owner/:repo/reports/:id { "status": "resolved"|"dismissed"|"escalated" }POST /api/content-safety/admin/blocklist { "hash": "sha256hex", "category": "..." } → 201
DELETE /api/content-safety/admin/blocklist/:hash → { "success": true }GET /api/users/:username
→ { "user": { "username", "display_name", "email", "created_at" }, "repositories": [...] }PATCH /api/users/:username
Auth: required (must be own profile)
{ "display_name?", "bio?", "avatar_url?" }
→ { "user": {...} }POST /api/users/me/avatar
Auth: required
Content-Type: multipart/form-data
Body: avatar=@photo.jpg (max 5MB, PNG/JPEG/GIF/WebP)
→ { "avatar_url": "...", "avatar_r2_key": "..." }
DELETE /api/users/me/avatar → { "avatar_url": "fallback-gravatar-url" }
GET /api/users/avatars/:key → binary imagePOST /api/orgs { "slug", "display_name", "description?", "avatar_url?" }
GET /api/orgs/:org → { "organization", "members_count", "teams_count", "repos_count" }GET /api/orgs/:org/members
POST /api/orgs/:org/members { "username", "role?": "owner"|"admin"|"member" }
PATCH /api/orgs/:org/members/:username { "role" }
DELETE /api/orgs/:org/members/:usernameGET /api/orgs/:org/teams
POST /api/orgs/:org/teams { "slug", "display_name", "description?", "permission?" }
GET /api/orgs/:org/teams/:team → { "team", "members": [...], "repos": [...] }
PATCH /api/orgs/:org/teams/:team { "display_name?", "description?", "permission?" }
DELETE /api/orgs/:org/teams/:team
POST /api/orgs/:org/teams/:team/members { "username" }
DELETE /api/orgs/:org/teams/:team/members/:username
POST /api/orgs/:org/teams/:team/repos { "repo_id", "permission?" }
DELETE /api/orgs/:org/teams/:team/repos/:repo_idGET /api/orgs/:org/repos
POST /api/orgs/:org/repos { "name", "description?", "visibility?": "public"|"unlisted"|"private", "license_spdx?" }visibility is the sole organization-repository visibility input.
These endpoints implement the Git Smart HTTP protocol. They are NOT JSON APIs — they use the git pkt-line binary protocol.
GET /:owner/:repo/info/refs?service=git-upload-pack # clone/fetch discovery
GET /:owner/:repo/info/refs?service=git-receive-pack # push discovery (auth required)
POST /:owner/:repo/git-upload-pack # clone/fetch data transfer
POST /:owner/:repo/git-receive-pack # push data transfer (auth required)Git authentication uses HTTP Basic Auth with the Forge username and a PAT as the password. A web-session JWT belongs in API Bearer headers and is not a Git password. See Agent Git Authentication Quickstart above.
Canonical clone URL: https://forge.smol.ai/<owner>/<repo>.git
URLs without the .git suffix are also supported.
This section describes how to configure AI coding agents (Claude Code, Codex, Cursor CLI, Factory Droid, Devin for Terminal, OpenCode, etc.) to automatically store their conversation transcripts in SmolForge alongside the commits they produce.
Recommended first step:
sf hooks install <owner>/<repo> --agent all --git post-commit,pre-push
sf hooks status <owner>/<repo>The CLI installs .smolforge/hooks/upload-transcript.sh, native lifecycle hook files where available, and Git hook backstops. Codex is supported through the Git hook path and ~/.codex/sessions JSONL discovery. Claude Code uses a PostToolUse hook. Cursor, Factory Droid, and Devin get repo-local lifecycle hooks plus the Git backstop. OpenCode is supported through the Git backstop with best-effort SQLite extraction when sqlite3 is available.
AI-Session: <session-id>Store these in your git config (or environment):
git config smolforge.token "your-jwt-token"
git config smolforge.url "https://forge.smol.ai"Or as environment variables:
export SMOLFORGE_TOKEN="your-jwt-token"
export SMOLFORGE_URL="https://forge.smol.ai"Upload an entire session's transcript for all commits at once. Use this when you've completed a session and want to attach the full conversation.
#!/bin/bash
# backfill-transcripts.sh
# Usage: ./backfill-transcripts.sh <owner> <repo> <session-file>
OWNER="$1"
REPO="$2"
SESSION_FILE="$3"
SESSION_ID=$(basename "$SESSION_FILE" .jsonl)
CF_TOKEN="${SMOLFORGE_TOKEN:-$(git config --get smolforge.token)}"
CF_URL="${SMOLFORGE_URL:-$(git config --get smolforge.url)}"
if [ -z "$CF_TOKEN" ] || [ -z "$CF_URL" ]; then
echo "Error: Set SMOLFORGE_TOKEN and SMOLFORGE_URL or git config smolforge.token/url"
exit 1
fi
# Find the most recent commit with this session's AI-Session trailer
COMMIT_SHA=$(git log --all --format='%H %B' | grep -B1 "AI-Session: $SESSION_ID" | head -1 | awk '{print $1}')
# If no commit has the trailer, use HEAD
if [ -z "$COMMIT_SHA" ]; then
COMMIT_SHA=$(git rev-parse HEAD)
echo "No commit found with AI-Session trailer. Using HEAD: $COMMIT_SHA"
fi
# Parse the JSONL transcript file into SmolForge upload format
python3 -c "
import json, sys
messages = []
for line in open('$SESSION_FILE'):
try:
entry = json.loads(line)
except:
continue
if entry.get('type') == 'human':
content = entry.get('message', {}).get('content', '')
if isinstance(content, list):
content = ' '.join(c.get('text', '') for c in content if isinstance(c, dict) and c.get('type') == 'text')
if content:
messages.append({
'role': 'user',
'content': content,
'timestamp': entry.get('timestamp', '')
})
elif entry.get('type') == 'assistant':
content = entry.get('message', {}).get('content', '')
if isinstance(content, list):
content = ' '.join(c.get('text', '') for c in content if isinstance(c, dict) and c.get('type') == 'text')
if content:
messages.append({
'role': 'assistant',
'content': content,
'timestamp': entry.get('timestamp', '')
})
payload = {
'session_id': '$SESSION_ID',
'agent_type': 'claude-code',
'commit_sha': '$COMMIT_SHA',
'messages': messages
}
print(json.dumps(payload))
" | curl -s -X POST "$CF_URL/api/repos/$OWNER/$REPO/transcripts" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d @- | python3 -m json.tool
echo "Transcript uploaded for session $SESSION_ID → commit $COMMIT_SHA"To find your Claude Code session files:
# List recent sessions
ls -lt ~/.claude/projects/*/sessions/*.jsonl 2>/dev/null | head -10
# The session file path encodes the project directory
# e.g. ~/.claude/projects/-home-user-myproject/sessions/4cf341c0-3bd3-4cec-b8c4-9e62a1f90eae.jsonlSet up a git post-commit hook that automatically uploads the transcript excerpt for each commit.
#!/bin/bash
# .git/hooks/post-commit
# Auto-uploads transcript for commits with AI-Session trailers
COMMIT_MSG=$(git log -1 --format=%B)
SESSION_ID=$(echo "$COMMIT_MSG" | grep "^AI-Session:" | awk '{print $2}')
if [ -z "$SESSION_ID" ]; then
exit 0 # No AI-Session trailer, skip
fi
SHA=$(git rev-parse HEAD)
CF_TOKEN="${SMOLFORGE_TOKEN:-$(git config --get smolforge.token)}"
CF_URL="${SMOLFORGE_URL:-$(git config --get smolforge.url || echo "https://forge.smol.ai")}"
REMOTE_URL=$(git remote get-url origin 2>/dev/null)
OWNER=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)/([^/.]+)(\.git)?$|\1|')
REPO=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)/([^/.]+)(\.git)?$|\2|')
if [ -z "$CF_TOKEN" ] || [ -z "$OWNER" ] || [ -z "$REPO" ]; then
exit 0
fi
# Find the session file
SESSION_FILE=$(find ~/.claude/projects/ -name "$SESSION_ID.jsonl" 2>/dev/null | head -1)
if [ -z "$SESSION_FILE" ]; then
# Try Codex location
SESSION_FILE=$(find ~/.codex/ -name "$SESSION_ID.json" 2>/dev/null | head -1)
fi
if [ -z "$SESSION_FILE" ]; then
echo "SmolForge: Session file not found for $SESSION_ID"
exit 0
fi
echo "SmolForge: Uploading transcript for session $SESSION_ID..."
python3 -c "
import json
messages = []
for line in open('$SESSION_FILE'):
try:
entry = json.loads(line)
except:
continue
if entry.get('type') == 'human':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'user','content':c,'timestamp':entry.get('timestamp','')})
elif entry.get('type') == 'assistant':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'assistant','content':c,'timestamp':entry.get('timestamp','')})
print(json.dumps({'session_id':'$SESSION_ID','agent_type':'claude-code','commit_sha':'$SHA','messages':messages}))
" | curl -s -X POST "$CF_URL/api/repos/$OWNER/$REPO/transcripts" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d @- > /dev/null
echo "SmolForge: Transcript uploaded."Make it executable: chmod +x .git/hooks/post-commit
Upload transcripts after pushing, so the commit SHA is finalized. Add to .git/hooks/post-push or call manually:
#!/bin/bash
# upload-transcripts.sh
# Scans recent commits for AI-Session trailers and uploads any missing transcripts
OWNER="$1"
REPO="$2"
BRANCH="${3:-main}"
LOOKBACK="${4:-10}" # number of recent commits to check
CF_TOKEN="${SMOLFORGE_TOKEN:-$(git config --get smolforge.token)}"
CF_URL="${SMOLFORGE_URL:-$(git config --get smolforge.url || echo "https://forge.smol.ai")}"
git log --format='%H|||%B' -n "$LOOKBACK" "$BRANCH" | while IFS='|||' read -r SHA MSG; do
SESSION_ID=$(echo "$MSG" | grep "^AI-Session:" | head -1 | awk '{print $2}')
[ -z "$SESSION_ID" ] && continue
# Check if transcript already exists
EXISTS=$(curl -s -o /dev/null -w "%{http_code}" \
"$CF_URL/api/repos/$OWNER/$REPO/commits/$SHA/transcript" \
-H "Authorization: Bearer $CF_TOKEN")
if [ "$EXISTS" = "200" ]; then
echo "Transcript already exists for $SHA ($SESSION_ID)"
continue
fi
SESSION_FILE=$(find ~/.claude/projects/ -name "$SESSION_ID.jsonl" 2>/dev/null | head -1)
[ -z "$SESSION_FILE" ] && continue
echo "Uploading transcript for $SHA ($SESSION_ID)..."
python3 -c "
import json
messages = []
for line in open('$SESSION_FILE'):
try: entry = json.loads(line)
except: continue
if entry.get('type') == 'human':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'user','content':c,'timestamp':entry.get('timestamp','')})
elif entry.get('type') == 'assistant':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'assistant','content':c,'timestamp':entry.get('timestamp','')})
print(json.dumps({'session_id':'$SESSION_ID','agent_type':'claude-code','commit_sha':'$SHA','messages':messages}))
" | curl -s -X POST "$CF_URL/api/repos/$OWNER/$REPO/transcripts" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d @-
echo "Done."
doneClaude Code's hook system can automatically upload transcripts whenever Claude makes a git commit. This is the cleanest approach — no git hooks needed, works across all repos.
Add this to .claude/settings.json (project-level) or ~/.claude/settings.json (global):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/smolforge-transcript.sh",
"timeout": 30
}
]
}
]
}
}Then create .claude/hooks/smolforge-transcript.sh:
#!/bin/bash
# Claude Code PostToolUse hook — auto-upload transcript after git commit
# Receives JSON on stdin with session_id, tool_input.command, cwd, etc.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
# Only handle actual git commits
echo "$COMMAND" | grep -q "git commit" || exit 0
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
[ -z "$SESSION_ID" ] && exit 0
SHA=$(cd "$CLAUDE_PROJECT_DIR" && git rev-parse HEAD 2>/dev/null)
[ -z "$SHA" ] && exit 0
CF_TOKEN="${SMOLFORGE_TOKEN:-$(git config --get smolforge.token 2>/dev/null)}"
CF_URL="${SMOLFORGE_URL:-$(git config --get smolforge.url 2>/dev/null || echo "https://forge.smol.ai")}"
REMOTE_URL=$(cd "$CLAUDE_PROJECT_DIR" && git remote get-url origin 2>/dev/null)
OWNER=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)/([^/.]+)(\.git)?$|\1|')
REPO=$(echo "$REMOTE_URL" | sed -E 's|.*/([^/]+)/([^/.]+)(\.git)?$|\2|')
[ -z "$CF_TOKEN" ] || [ -z "$OWNER" ] || [ -z "$REPO" ] && exit 0
# Find session transcript file
SESSION_FILE=$(find ~/.claude/projects/ -name "$SESSION_ID.jsonl" 2>/dev/null | head -1)
[ -z "$SESSION_FILE" ] && exit 0
# Parse and upload (background to not block Claude)
(
python3 -c "
import json
messages = []
for line in open('$SESSION_FILE'):
try: entry = json.loads(line)
except: continue
if entry.get('type') == 'human':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'user','content':c,'timestamp':entry.get('timestamp','')})
elif entry.get('type') == 'assistant':
c = entry.get('message',{}).get('content','')
if isinstance(c, list): c = ' '.join(x.get('text','') for x in c if isinstance(x,dict) and x.get('type')=='text')
if c: messages.append({'role':'assistant','content':c,'timestamp':entry.get('timestamp','')})
print(json.dumps({'session_id':'$SESSION_ID','agent_type':'claude-code','commit_sha':'$SHA','messages':messages}))
" | curl -s -X POST "$CF_URL/api/repos/$OWNER/$REPO/transcripts" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d @- > /dev/null 2>&1
) &
exit 0chmod +x .claude/hooks/smolforge-transcript.shHow it works: Claude Code fires PostToolUse after every Bash tool call. The if: "Bash(git commit *)" condition ensures the hook only runs when the command was a git commit. The hook reads the session ID from stdin JSON, finds the transcript file, and uploads it in the background.
Note: Claude Code does not yet have a dedicated PostCommit event (see github.com/anthropics/claude-code/issues/4834), so we use PostToolUse with a Bash matcher as the recommended workaround.
OpenAI Codex CLI persists JSONL sessions under ~/.codex/sessions. SmolForge supports Codex through the CLI-managed Git hook path:
sf hooks install <owner>/<repo> --agent codex --git post-commit,pre-pushThe generated hook uploads the newest Codex JSONL with agent_type set to "codex" and the current commit SHA.
For current CLI agents:
~/.cursor/projects/<workspace>/agent-transcripts/...; SmolForge can discover those files and also installs a repo-local Cursor stop hook.~/.factory/sessions/... and its hook payload includes session_id and transcript_path; SmolForge installs a SessionEnd hook.~/.local/share/devin/cli/sessions.db on current builds, with older installs under ~/.config/devin/cli/sessions.db; SmolForge installs a SessionEnd hook and extracts a JSONL view when sqlite3 is available.~/.local/share/opencode/opencode*.db; SmolForge uses the Git backstop and extracts a JSONL view when sqlite3 is available. Older JSON transcript stores are also scanned when present.When committing from an AI agent session, include the trailer in the commit message:
# Claude Code — find session ID from the JSONL filename
SESSION_ID=$(ls -t ~/.claude/projects/*/sessions/*.jsonl 2>/dev/null | head -1 | xargs basename | sed 's/.jsonl//')
git commit -m "Implement feature X
AI-Session: $SESSION_ID
Co-Authored-By: Claude <noreply@anthropic.com>"The trailer must be in the commit message body (after a blank line from the subject), formatted as Key: Value. Git trailers survive rebases, cherry-picks, and format-patch.
The transcript format is agent-agnostic. Set agent_type to identify the source:
{
"session_id": "any-unique-id",
"agent_type": "codex",
"commit_sha": "...",
"messages": [
{ "role": "user", "content": "Fix the login bug", "timestamp": "..." },
{ "role": "assistant", "content": "I found the issue in auth.ts...", "timestamp": "..." }
]
}Supported agent_type values (convention, not enforced): claude-code, claude-cowork, codex, cursor, factory, devin, opencode, copilot, aider, continue, custom.
If your agent session included screenshots or images:
# Upload image first
RESULT=$(curl -s -X POST "$CF_URL/api/repos/$OWNER/$REPO/transcripts/$SESSION_ID/images" \
-H "Authorization: Bearer $CF_TOKEN" \
-F "file=@screenshot.png")
IMAGE_KEY=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['key'])")
# Then include the key in the message's image_keys array
# { "role": "user", "content": "Here's the error", "image_keys": ["$IMAGE_KEY"] }Public repositories may expose an exact-commit Wiki:
GET /api/repos/:owner/:repo/wiki
GET /api/repos/:owner/:repo/wiki/pages/:slug
GET /api/repos/:owner/:repo/wiki/search-indexSigned-in users can ask cited questions with:
POST /api/repos/:owner/:repo/wiki/ask
{ "question": "How does this repository handle authentication?" }Forge also exposes a stateless Streamable HTTP MCP server at POST /mcp/wiki.
Its tools are read_wiki_structure, read_wiki_contents, and
ask_question. Public read tools need no token. ask_question requires a
personal access token with the wiki:ask scope.
Every Wiki response reports its pinned source SHA. Citation links use
/:owner/:repo/blob/:sha/*; Forge rejects stored commits that are not
reachable from the repository's current refs.
Every repository exposes a durable, creator-private multi-turn agent thread API:
GET /api/repos/:owner/:repo/agent/execution-profiles
POST /api/repos/:owner/:repo/agent/threads
GET /api/repos/:owner/:repo/agent/threads
GET /api/repos/:owner/:repo/agent/threads/:thread_id
POST /api/repos/:owner/:repo/agent/threads/:thread_id/messages
GET /api/repos/:owner/:repo/agent/threads/:thread_id/events?after=:cursorThread handles use agent_thread_ followed by 32 lowercase hex characters.
If thread_id is omitted, Forge assigns and returns one. Messages and runs use
agent_message_ and agent_run_ handles with the same suffix rule.
The repository-agent API does not use Idempotency-Key. A connector that needs
exact retry safety may supply client_message_id for one message. The digest
also binds normalized input, source, and execution request. Reusing a thread is
a new conversational turn, not a duplicate request.
Personal access tokens use agent:read, agent:message, and agent:run,
optionally constrained to one repository. Cancellation and approval use
agent:cancel and agent:approve; Workspace Alpha additionally requires
agent:write, repository write permission, and an explicit server allowlist
match. Repository-scoped agent service principals provide separately rotatable
API keys for bots and automation.
Instant is a real Worker-native, read-only agent over a deterministically
selected, bounded exact-SHA evidence set. It cannot execute code, run tests,
use tools or secrets, or write a branch. Allowlisted Workspace Alpha runs in a
run-scoped Cloudflare Sandbox and can publish only through Forge's independent,
tested, source-SHA-fenced draft-pull-request path. Every terminal run exposes
an immutable metadata-only execution receipt. See
/docs/docs/repository-agent-v1 for the complete contract.
GET /health
→ { "status": "ok", "timestamp": "2026-03-29T08:00:00.000Z" }