# SmolForge — llms.txt > 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. --- ## Start here: CLI, authentication, and agent context 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. ```bash npm install -g @smolai/forge sf auth login --username sf auth status sf llms --save llms.txt ``` Use `sf auth login --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: ```bash sf help sf commands --json sf schema --json ``` `sf 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: ```bash sf auth login git ls-remote origin HEAD ``` `sf 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: ```bash sf hooks install / --agent all --git post-commit,pre-push sf hooks status / ``` --- ## Accounts, teams, and service tiers 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. --- ## Forge repository agents 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 for deployed applications 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: - logical route `forge/text-fast@1`; - host-funded, non-streaming text inference; - native Forge JS through `env.forge.ai.responses.create(...)`; - same-account connected Cloudflare Workers through the private `env.FORGE_AI.responsesCreate(...)` RPC binding; - metadata-only usage receipts with content logging off; and - profile, request-rate, production-cost, and preview-cost ceilings. 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 --- ## Releasing the Forge platform 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. ```bash 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 --json ``` Affected 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: - https://forge.smol.ai/docs/docs/deploy/release-state-machine-v2 - https://forge.smol.ai/docs/docs/operations/production-deployment - https://forge.smol.ai/blog --- ## Why Forge Deploy instead of a raw Cloudflare deploy? Cloudflare provides Forge's runtime, storage, networking, and state primitives. Forge Deploy adds a Git-native release control plane above them: - one exact pushed Git SHA identifies the source; - configuration, application code, and assets are frozen into one deployment; - every successful build receives an immutable preview before production; - automatic activation fails closed if the SHA is no longer the configured branch head or required checks and protection policy no longer pass; - provider publication, preview readiness, and production activation are recorded as distinct states; - an activation retry recognizes when the exact target already became active, records the provider receipt, and completes Forge's pointer without changing traffic again; a different active version fails closed; - deployment evidence joins the source, configuration, build, provider version, preview, and activation outcome; and - rollback moves the production pointer to a prior ready deployment without rebuilding or rewinding durable state. 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 --- ## Pull request deploy previews 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--.sites.smol.ai 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 --- ## Forge blog 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. --- ## JavaScript/TypeScript package-manager policy 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: `pnpm-lock.yaml` (recommended default) - Bun: `bun.lock` (high-speed option) - npm compatibility: `package-lock.json` or `npm-shrinkwrap.json` - Yarn compatibility: `yarn.lock` When 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. --- ## Migrate an existing repository to Forge Deploy 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: ```bash git remote set-url origin https://forge.smol.ai//.git git push -u origin "$(git branch --show-current)" sf migrate ``` For multi-repository GitHub migrations, `sf repo import github ` 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 --- ## Agent Git Authentication Quickstart Browser login authenticates the web app only. Use the Forge CLI to configure local Git access; do not copy browser credentials into a terminal. ### Paste this into an agent with browser access ```text 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//.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. ```bash npm install -g @smolai/forge sf auth login --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. --- ## Authentication 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 ``` ### Get a 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 → { "user": {...} } ``` ``` GET /api/auth/whoami Authorization: Bearer → { "authenticated": true, "auth_type": "session"|"personal_access_token", "user": {...} } ``` ### Personal Access Tokens 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 { "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 → { "personal_access_tokens": [...metadata] } ``` ``` DELETE /api/auth/tokens/:id Authorization: Bearer → 204 ``` --- ## Gists Gists 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=` for one owner's public gists, optional `q=` 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) → 204 ``` Each 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. --- ## Repositories ``` 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" } ``` ### Stars ``` 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 } ``` --- ## File Contents ``` 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": "", "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" } } } ``` --- ## Commits ``` 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" } ``` --- ## Branches ``` 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) → 204 ``` --- ## Issues ``` GET /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": {...} } ``` ### Labels ``` 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 all ``` --- ## Pull Requests ``` GET /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": "" } → { "merged": true, "method": "fast-forward", "merge_commit_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. ``` --- ## CI/CD Actions 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:unit ``` ## Forge Deploy Forge 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: ```bash npm install --save-dev @smolai/forge ``` ```ts import { 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: ```bash npx smolforge deploy check git push origin main ``` Forge 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: - Preview guide: https://forge.smol.ai/swyx/forge/blob/main/docs/deploy/forge-deploy-preview.md - Config v1: https://forge.smol.ai/spec/deploy/v1 - Schema: https://forge.smol.ai/spec/deploy/v1/schema.json - npm: https://www.npmjs.com/package/@smolai/forge - Fixtures: https://forge.smol.ai/swyx/forge/tree/main/examples/forge-deploy 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/promote ``` Do 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. ### Workflows ``` 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 → 204 ``` ### Runs ``` GET /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": {...} } ``` ### Jobs & Logs ``` 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" }] }] } ``` ### Artifacts & Secrets ``` 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 → 204 ``` --- ## AI Transcripts SmolForge stores AI coding agent conversations (Claude Code, Codex, Copilot, etc.) alongside commits. This is the core integration point for agents. ### Upload a transcript ``` 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. ### List transcripts ``` GET /api/repos/:owner/:repo/transcripts?page=1&limit=20 Auth: required → { "transcripts": [...], "pagination": { "page", "limit", "total", "pages" } } ``` ### Get transcript by session ID ``` 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 transcript by commit SHA ``` GET /api/repos/:owner/:repo/commits/:sha/transcript?full=true Auth: required → same as above ``` ### Upload transcript images ``` POST /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=31536000 ``` --- ## Secret Masks Secret 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}` --- ## Webhooks ``` 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 name - `X-SmolForge-Delivery`: unique delivery UUID - `X-SmolForge-Signature`: `sha256=` (only if secret is configured) --- ## Content Safety ``` 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 } ``` --- ## Users & Profiles ``` 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 image ``` --- ## Organizations ``` POST /api/orgs { "slug", "display_name", "description?", "avatar_url?" } GET /api/orgs/:org → { "organization", "members_count", "teams_count", "repos_count" } ``` ### Members ``` 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/:username ``` ### Teams ``` GET /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_id ``` ### Org Repos ``` GET /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. --- ## Git Smart HTTP (Clone/Push) 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//.git` URLs without the `.git` suffix are also supported. --- ## Automatic Transcript Attachment for AI Coding Agents 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: ```bash sf hooks install / --agent all --git post-commit,pre-push sf hooks status / ``` 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. ### Concept 1. The agent does work and makes git commits 2. Each commit includes a git trailer: `AI-Session: ` 3. After pushing, the transcript is uploaded to SmolForge's API with that session ID and commit SHA 4. SmolForge links them — the transcript appears on the commit detail page in the UI ### Prerequisites Store these in your git config (or environment): ```bash git config smolforge.token "your-jwt-token" git config smolforge.url "https://forge.smol.ai" ``` Or as environment variables: ```bash export SMOLFORGE_TOKEN="your-jwt-token" export SMOLFORGE_URL="https://forge.smol.ai" ``` ### Method 1: Backfill from Scratch (Claude Code) 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. ```bash #!/bin/bash # backfill-transcripts.sh # Usage: ./backfill-transcripts.sh 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: ```bash # 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.jsonl ``` ### Method 2: Incremental (Auto-Upload on Each Commit) Set up a git post-commit hook that automatically uploads the transcript excerpt for each commit. ```bash #!/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` ### Method 3: Post-Push Hook (Recommended for Teams) Upload transcripts after pushing, so the commit SHA is finalized. Add to `.git/hooks/post-push` or call manually: ```bash #!/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." done ``` ### Method 4: Claude Code Hooks (Recommended — Zero Config After Setup) Claude 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): ```json { "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`: ```bash #!/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 0 ``` ```bash chmod +x .claude/hooks/smolforge-transcript.sh ``` **How 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](https://github.com/anthropics/claude-code/issues/4834)), so we use `PostToolUse` with a Bash matcher as the recommended workaround. ### Method 5: Codex CLI OpenAI Codex CLI persists JSONL sessions under `~/.codex/sessions`. SmolForge supports Codex through the CLI-managed Git hook path: ```bash sf hooks install / --agent codex --git post-commit,pre-push ``` The generated hook uploads the newest Codex JSONL with `agent_type` set to `"codex"` and the current commit SHA. ### Method 6: Cursor, Factory Droid, Devin, and OpenCode For current CLI agents: - Cursor CLI stores JSONL transcripts under `~/.cursor/projects//agent-transcripts/...`; SmolForge can discover those files and also installs a repo-local Cursor stop hook. - Factory Droid stores JSONL sessions under `~/.factory/sessions/...` and its hook payload includes `session_id` and `transcript_path`; SmolForge installs a `SessionEnd` hook. - Devin for Terminal stores sessions in SQLite at `~/.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. - OpenCode current builds store sessions in SQLite under `~/.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. --- ### How to Add the AI-Session Trailer to Commits When committing from an AI agent session, include the trailer in the commit message: ```bash # 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 " ``` 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. ### For Other Agents The transcript format is agent-agnostic. Set `agent_type` to identify the source: ```json { "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`. ### Uploading Images from Transcripts If your agent session included screenshots or images: ```bash # 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"] } ``` --- ## Forge Wiki 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-index ``` Signed-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. --- ## Forge Repository Agents V1 Every repository exposes a durable, creator-private multi-turn agent thread API: ```text 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=:cursor ``` Thread 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. --- ## Health Check ``` GET /health → { "status": "ok", "timestamp": "2026-03-29T08:00:00.000Z" } ```