GitPanda v3.1.1 Install
Reference

API

The GitPanda REST API behind the command line, with API keys, scopes, the device sign-in flow and every endpoint.

Everything gitpanda does goes through a small REST API, which you can call yourself with the same keys.

  • Base URL: https://okaypanda.com/api/v1
  • Format: JSON in and out, except raw file downloads.
  • Auth: Authorization: Bearer opk_… on every request except sign-in.
  • Errors: a status code and { "error": "…" } with a plain-language message.
curl -s https://okaypanda.com/api/v1/me -H "Authorization: Bearer $GITPANDA_KEY"

Keys and scopes#

A key (opk_ followed by 48 hex characters) acts as one person in one company. Get one from browser sign-in (device flow) or make one in the app (GitPanda → Command line → Use an API key instead).

Scope Allows
read Every GET endpoint. Always included.
work Pushing documents, asking rooms, starting and messaging goals, code.
approve Deciding approvals and auto mode (owners and admins).

The person's role is checked on every request: a viewer's key can only read, and approving needs an owner or admin whatever the scopes. Revoked keys stop at once (401).

Rate limit: 240 requests a minute per key (429 beyond that).

Status Meaning
400 The request isn't valid (the message says what).
401 Missing, invalid or revoked key.
403 The key lacks a scope, or the person's role doesn't allow it.
404 Not found, or not visible to this key (other companies' things are always 404).
409 A conflict: changed since you pulled, already decided, a precondition not met.
429 Too many requests.

Account#

GET /v1/me#

json
{
  "user": { "email": "you@acme.com", "name": "You" },
  "company": { "id": "1a2b…", "name": "Acme" },
  "role": "owner",
  "scopes": ["read", "work"],
  "key": "gitpanda on my-mac",
  "repo": { "slug": "acme", "visibility": "company", "url": "https://okaypanda.com/git/acme" }
}

Repository#

GET /v1/repo#

The file tree, built live.

json
{
  "slug": "acme",
  "at": 1760720000000,
  "tree": [
    { "path": "marketing/docs/launch-plan.md", "kind": "md", "size": 1834, "updated_at": 1760719000000, "editable": true },
    { "path": "marketing/README.md", "kind": "md", "size": 2210, "updated_at": 1760719500000, "editable": false },
    { "path": "marketing/files/pricing-report.pdf", "kind": "file", "size": 48211, "updated_at": 1760710000000, "editable": false }
  ]
}

kind is md, file or image. updated_at is milliseconds since the epoch; use it as base when pushing. Locked rooms are included only for owners and admins.

GET /v1/repo/raw?path=<path>#

One file's contents, with its real content type and an x-updated-at header. Markdown is text; files and images are bytes.

GET /v1/repo/log?path=<path>&limit=<n>#

json
{ "commits": [
  { "sha": "7b72458", "message": "Add Spring sale plan", "author": "Sol", "ai": true, "at": 1760719000000, "path": "marketing/docs/spring-sale-plan.md" }
] }

path filters to a file or folder. limit is at most 200.

POST /v1/repo/push (work)#

json
{ "path": "marketing/docs/launch-plan.md", "content": "# Launch plan\n\n…", "base": 1760719000000, "message": "Lead with the price", "force": false }
  • An existing document: saved as a new version. If it changed after base, the answer is 409 with "conflict": true, unless force is true. Answers { "ok": true, "path", "updated_at", "version" }, or "unchanged": true if nothing changed.
  • A new document at docs/<name>.md or <room>/docs/<name>.md: created with its first # Heading as the title. Answers 201 with "created": true and the path the repository gives it.
  • READMEs, generated files, files, images and paths outside docs/ are refused with 400.
  • Content is at most 60,000 characters.

AI#

All of these read only what the key can see (locked rooms only for owners and admins), use the company's AI budget, and are limited to 20 requests a minute and 300 a day per key. Long answers arrive after a few keep-alive spaces; parse the trimmed body.

GET /v1/repo/search?q=<words>&limit=<n>#

Full-text search (no AI): { "results": [ { "path", "title", "snippet", "updated_at" } ] }. Matches in snippet are wrapped in **.

POST /v1/repo/ask#

{ "question": "What's our refund policy?" } →

json
{ "answer": "Refunds within **30 days**…", "confident": true,
  "sources": [ { "path": "marketing/docs/refund-policy.md", "why": "The policy itself" } ],
  "considered": ["README.md", "marketing/docs/refund-policy.md"] }

sources are always among considered, the files the AI read.

POST /v1/ai/commit-message#

{ "changes": [ { "path", "before": "…" | null, "after": "…" } ] } (up to 20 files) → { "message" }.

POST /v1/ai/review#

The same changes →

json
{ "verdict": "looks_good" | "suggestions" | "needs_changes", "summary": "…",
  "comments": [ { "path", "severity": "issue" | "suggestion" | "praise", "quote": "…", "note": "…" } ],
  "compared_with": ["sales/docs/price-list.md"] }

POST /v1/ai/edit (work)#

{ "path": "marketing/docs/plan.md", "content": "…current text…", "instruction": "Cut it to 200 words" } → { "content": "…the whole edited document…", "summary": "…" }. Nothing is saved; push it yourself.

GET /v1/repo/explain?path=<path>#

{ "path", "summary", "points": [], "ask": [] }. 404 if there's no such file.

GET /v1/repo/recap?days=<1–90>#

{ "days", "summary", "highlights": [], "commits", "contributors": [ { "name", "ai", "changes", "last" } ] }

POST /v1/ai/intent#

{ "text": "make the launch plan lead with the price" } → { "command": "edit", "args": ["marketing/docs/launch-plan.md", "lead with the price"], "explain": "…" }. command is one of ask, search, edit, request, goal, check, recap, why, explain, save, get, status, now. Nothing runs; the client shows it and runs it.

Team in the repository#

POST /v1/repo/check#

{ "score": 76, "checked": 42, "issues": [ { "id", "kind": "contradiction" | "stale" | "thin" | "broken_link" | "no_docs", "severity": "high" | "medium" | "low", "paths": [], "note", "fix" } ] }. Uses the AI for contradictions.

POST /v1/repo/check/fix (work)#

{ "issues": [ …from check… ] } (up to 10) → { "started": [ { "goal": { "id", "seq" }, "room", "who", "paths" } | { "error", "paths" } ] }.

POST /v1/repo/request (work; auto needs approve)#

{ "target": "marketing/docs/launch-plan.md" | "marketing", "text": "…", "auto": false } → 201 { "goal": { "id", "seq", "title" }, "room", "who" }. A document is updated in place as a new version; a room gets a new document. READMEs and files can't be requested (400); unknown paths or rooms are 404.

GET /v1/repo/why?path=<path>#

{ "path", "title", "version", "versions": [ { "version", "at", "author", "ai", "role", "message", "task": { "seq", "title", "status" }, "goal": { "seq", "title" }, "summary" } ] }, newest first.

GET /v1/repo/now#

{ "working": [ { "who", "room", "task": { "seq", "title" }, "goal", "writing": "the document's title" | null } ] }.

GET /v1/repo/contributors#

{ "contributors": [ { "name", "ai", "changes", "last" } ] } (no AI).

Team#

GET /v1/rooms#

json
{ "rooms": [ { "id": "…", "name": "Marketing", "slug": "marketing", "locked": 0, "lead": "Sol", "staff": 2, "running": 1, "open": 3 } ] }

POST /v1/ask (work)#

json
{ "room": "marketing", "text": "How did the launch go?" }
json
{ "room": "Marketing", "lead": "Sol", "reply": "…", "task": { "id": "…", "seq": 48, "title": "…" } }

task is set when the lead queued work. This call can take several seconds.

Goals#

POST /v1/goals (work; auto needs approve)#

json
{ "text": "Plan the spring sale", "room": "marketing", "auto": false }

Answers 201 with { "goal": { "id", "seq", "title", "status" } }. Without room, the Coordinator plans it. Without auto, the company's mode applies.

GET /v1/goals?all=1#

Open goals (or all of them), with total and done task counts.

GET /v1/goals/<id or G-n>#

json
{
  "goal": { "id": "…", "seq": 35, "title": "…", "status": "running", "auto": 0, "summary": "…" },
  "tasks": [ { "id": "…", "seq": 36, "title": "…", "status": "completed", "summary": "…", "waiting_for": null, "who": "Sol", "room": "Marketing" } ],
  "messages": [ { "role": "agent", "kind": "result", "body": "…", "who": "Sol", "created_at": 1760719000000 } ],
  "approvals": [ { "id": "…", "kind": "action", "action": "send_email", "reasoning": "…", "risk": "medium" } ],
  "files": [ { "id": "…", "name": "subject-lines.pdf", "type": "application/pdf", "size": 18211 } ],
  "pull_requests": ["https://github.com/acme/website/pull/12"]
}

GET /v1/goals/<id or G-n>/log?after=<ms>#

The live work log: rows updated after after, oldest first, and at to pass next time. Poll every one to three seconds to stream it.

json
{ "goal": { "id": "…", "seq": 35, "status": "running" },
  "log": [ { "id": "…", "kind": "tool", "label": "write_document", "body": "…", "live": 0, "who": "Sol", "meta": "{\"decision\":\"allowed\"}", "updated_at": 1760719000000 } ],
  "at": 1760719000000 }

kind is thinking, text, tool, result or search. live is 1 while a row is still being written.

POST /v1/goals/<id or G-n>/message (work)#

{ "text": "Make it shorter" } → { "reply", "kind", "task" }. kind says how it was taken: a question, an update or new work.

GET /v1/files/<id>#

Download a file a goal made (an attachment).

Approvals#

GET /v1/approvals#

Pending approvals: id, kind, action, reasoning, expected_outcome, risk, the task and the employee.

POST /v1/approvals/<id> (approve; owners and admins)#

{ "decision": "approved" | "rejected", "note": "…" }. 409 if it was already decided.

Code#

POST /v1/code (work; auto needs approve)#

json
{ "text": "Add a size guide page", "repo": "acme/website", "room": "engineering", "auto": false }

409 if GitHub isn't connected or no one may open pull requests; 400 if repo isn't one the connection can use. Answers 201 with the goal; follow it with the goals endpoints, and read pull_requests when it's done.

Device sign-in#

How gitpanda login signs in without a key, like GitHub's device flow. No Authorization header.

1. POST /v1/device#

{ "client": "my-tool on my-mac" } (shown to the person approving) →

json
{ "device_code": "<64 hex, keep it secret>", "user_code": "WDJB-MJHT",
  "verification_uri": "https://okaypanda.com/app/device",
  "verification_uri_complete": "https://okaypanda.com/app/device?code=WDJB-MJHT",
  "interval": 3, "expires_in": 900 }

Show user_code and open verification_uri_complete. 30 requests an hour per IP address.

2. The person approves#

At /app/device, signed in, they check the code, pick companies and scopes, and approve or cancel.

3. POST /v1/device/token#

Poll every interval seconds with { "device_code": "…" }:

status Meaning
pending Not approved yet. Keep polling.
slow_down Poll less often.
denied They cancelled. Stop.
expired The code expired or was already used. Start again.
approved Done, once.
json
{ "status": "approved", "user": { "email": "you@acme.com" },
  "accounts": [ { "org": "…", "company": "Acme", "slug": "acme", "url": "https://okaypanda.com/git/acme", "role": "owner", "scopes": ["read", "work"], "key": "opk_…" } ] }

The keys are created at this moment and returned once; the server never stores them.

Public repository pages#

Public repositories (and any you can see, with a session) have simple read endpoints, used by the web page:

  • GET /api/git/<slug>: the tree and recent commits
  • GET /api/git/<slug>/raw?path=<path>: one file
  • GET /api/git/<slug>/zip: the whole repository as a .zip
  • GET /api/git/<slug>/search?q=: search (anyone who can see it)
  • GET /api/git/<slug>/contributors: contributors (anyone who can see it)
  • POST /api/git/<slug>/ask, GET /api/git/<slug>/recap?days=, GET /api/git/<slug>/explain?path=: the AI, for signed-in members and people it's shared with (401 signed out, 403 otherwise)
  • GET /api/git/<slug>/why?path=, GET /api/git/<slug>/now: the story and who's working (anyone who can see it)
  • POST /api/git/<slug>/check: the health check (members and shares)
  • POST /api/orgs/<org>/repo/request, POST /api/orgs/<org>/repo/fix: requests and fixes from the page (signed-in members)