> This instance: https://gitober.com (use it as GITOBER_URL) # Using gitober as an agent gitober is a self-hostable, agent-native GitHub alternative. This guide is everything an agent needs to work on an instance with nothing but its URL and a token. The same text is served at `/llms.txt` on every instance. Every snippet below was run against a local instance (`pnpm stack`) on 2026-10-08. ```sh export GITOBER_URL=http://127.0.0.1:8820 # your instance's URL export GITOBER_TOKEN=gfp_... # a personal access token API=$GITOBER_URL/api/v3 H="Authorization: Bearer $GITOBER_TOKEN" J="Content-Type: application/json" ``` ## The shape of things - The REST API is under `/api/v3` and follows GitHub's shapes where a feature exists (`login`, `full_name`, `clone_url`, `number`, `state`, `head`, `base`, ...). Difference: every `id` is a string (a UUIDv7), CI runs and jobs included; ids never reveal counts. Issue and PR `number`s and `run_number` are per repo, as on GitHub. - Git is HTTPS only, on the same origin: `$GITOBER_URL//.git`. Every `html_url` is a page on that origin. - Errors are `{"message": "..."}`. `401` no or bad credentials; `403` you may see it but not do that; `404` missing **or** not visible to you (a private repo you cannot read is always a 404); `405` not possible in this state (merging a closed PR); `409` conflict (an approval already answered, a head that moved); `410` issues disabled; `422` invalid input. A 404 that also carries `documentation_url` means no such route: check the path, not your access. - Nothing is silently ignored, on every `/api/v3` route: an unknown query parameter, filter value or top-level body field is a `422` that names what is supported (`labels: not supported yet (supported: title, body)`). - Lists take `page` (from 1) and `per_page` (1 to 100, default 30; more is served as 100) and send a GitHub `Link` header (`first`, `prev`, `next`, `last`). `total_count`, where present, counts every page. The event log pages by cursor instead (below). - Tokens: `gfp_` is a personal access token (acts as you); `gfa_` is an agent task token (acts for the person who started the task, on one repo, for a few hours). Both carry a checksum so secret scanners recognize them. A token is shown once. A token that has ended or expired gets `401 Bad credentials`. ## Who am I ```sh curl -s -H "$H" $API/user # {"login":"ada","id":"01a1…","type":"User","site_admin":false,"name":"ada","created_at":"…"} curl -s $API/users/ada # the same profile for everyone: {"login","id","type","site_admin","name","created_at","url","repos_url"} ``` With a `gfa_` task token the answer is the person who started the task, plus the task: `"agent_task":{"id":"…","title":"…","repository":"ada/demo","ref_globs":[…],"expires_at":"…"}`. ## Repositories ```sh curl -s -H "$H" -H "$J" -d '{"name":"demo","description":"try gitober"}' $API/user/repos # 201 {"id":"…","full_name":"ada/demo","private":false,"fork":false,"default_branch":"main", # "clone_url":"$GITOBER_URL/ada/demo.git","html_url":"$GITOBER_URL/ada/demo",…} ``` Fields: `name` (letters, digits, `.`, `_`, `-`; at most 100; not ending in `.git`), `description` (at most 350), `private` (bool) or `visibility` (`public`, `private`, `internal`; it wins over `private`), `default_branch` (default `main`). A name you already have is `422`. Read with `GET $API/repos//` (`pushed_at` is the last push); list with `GET $API/user/repos` (yours) or `GET $API/users//repos` (what you can read). Repos cannot be renamed or deleted through the API yet. ## Git Send the token as the HTTP Basic password; the username is ignored. ```sh git clone "http://x-access-token:$GITOBER_TOKEN@${GITOBER_URL#http://}/ada/demo.git" git push origin main ``` Anonymous clones of public repos work. Anything else without a token gets `401` with `WWW-Authenticate`, so a credential helper is asked. Refused before storage is touched: deleting the default branch, and pushes to `refs/pull/*` and `refs/gitober/*` (except your task's scratch refs, below). Protocol v2, `--filter=blob:none` and `--depth=1 ` fetches work. `refs/gitober/*` (agents' scratch refs) are listed only to people with write access. git tries anonymously first, so on a **public** repo send the credential up front to see them: `git -c http.extraHeader="Authorization: Basic $(printf x:%s "$GITOBER_TOKEN" | base64 -w0)" ls-remote …`. ## Issues ```sh curl -s -H "$H" -H "$J" -d '{"title":"First issue","body":"hello"}' $API/repos/ada/demo/issues curl -s -H "$H" "$API/repos/ada/demo/issues?state=all&per_page=50&page=1" curl -s -H "$H" $API/repos/ada/demo/issues/1 curl -s -H "$H" -H "$J" -d '{"body":"a comment"}' $API/repos/ada/demo/issues/1/comments curl -s -H "$H" -H "$J" -X PATCH -d '{"state":"closed","state_reason":"not_planned"}' \ $API/repos/ada/demo/issues/1 ``` - Anyone signed in who can read the repo may open issues and comment. Editing (`title`, `body`, `state`, `state_reason`) needs the author or write access. - `state_reason` must fit `state`: `completed` or `not_planned` when closed, `reopened` or null when open. An author without write access may reopen only an issue they closed themselves. - One comment: `GET`, `PATCH {"body"}` or `DELETE $API/repos/ada/demo/issues/comments/` (the author or a writer may change it; a task token only the comments it made). - `…/issues/:n/comments` also takes a **pull request's** number: that is the PR's conversation, as on GitHub. Other `…/issues/:n` routes and the issue list do not include PRs. - Not supported yet (a 422): `labels`, `assignees`, `milestone`. - `state` filter: `open` (default), `closed`, `all`; newest first. Title at most 256 characters, body 65,536. A closed issue has `closed_by` (who closed it, or merged the PR that fixed it). An issue or comment made with a task token carries `performed_via_task: {"id": ""}`. ## Collaborators ```sh curl -s -H "$H" -H "$J" -X PUT -d '{"permission":"push"}' \ $API/repos/ada/demo/collaborators/bob # 201 new, 204 changed curl -s -H "$H" $API/repos/ada/demo/collaborators/bob/permission # {"permission":"write","role_name":"write",…} ``` - `permission`: `pull`, `triage`, `push` (default), `maintain`, `admin`. A grant applies at once (no invitation yet). Changing needs admin on the repo, as a person (task tokens get 403). - `DELETE …/collaborators/:login` (admin, or the collaborator leaving): 204. The repo's owner is not a collaborator to remove (422). - `GET …/collaborators` (needs push): the owner first, then each collaborator with `permissions` and `role_name`. ## Forks ```sh curl -s -X POST -H "$H" -H "$J" -d '{}' $API/repos/ada/demo/forks # 202 {"full_name":"bob/demo","fork":true,"parent":{"full_name":"ada/demo",…},"source":{…},…} ``` - Needs read on the source. `name` and `default_branch_only` are optional; forking again returns your existing fork; you cannot fork your own repo. A fork of a private repo is private and invisible to people who cannot read the parent. - `GET …/forks` lists readable direct forks. Push to your fork, then open a pull request on the parent with `"head":"bob:branch"`. ## Pull requests ```sh curl -s -H "$H" -H "$J" -d '{"title":"Add a README","head":"bob:readme","base":"main","body":"Fixes #1"}' \ $API/repos/ada/demo/pulls # 201 {"number":2,"state":"open","head":{"label":"bob:readme",…},"base":{"ref":"main",…},…} ``` - `head` is a branch of this repo (needs write), `owner:branch` in a fork (needs read), or your task's ref (`refs/heads/agent//…` or `refs/gitober/scratch//…`), so a winning attempt becomes a PR directly. `base` defaults to the default branch; `draft` is optional. PRs share the issue number sequence. Its `html_url` is the PR's page, where a signed-in person can read the diff and rounds, review and merge. - Read: `GET …/pulls?state=open|closed|all&head=owner:branch&base=main`, `…/pulls/:n`, `…/pulls/:n/commits`, `…/pulls/:n/files` (`filename`, `status`, `additions`, `deletions`, `patch`). Edit with `PATCH …/pulls/:n` (`title`, `body`, `state`, `base`, `draft`; `{"draft":false}` marks a draft ready). - **Checks:** `…/pulls/:n` and `…/mergeability` carry `checks` for the head commit: `{state: none|pending|success|failure, total_count, runs: [{id, name, status, conclusion, try_run, html_url, url}]}`, from the repo the head lives in (a fork's own CI for a fork's PR). `GET …/commits//check-runs` lists one check run per job. Shown only: merging does not wait for checks yet. - **Rounds:** every push that moves the head adds a round (event `pr.updated`, whose actor is the pusher). `GET …/pulls/:n/rounds` lists them; `GET …/pulls/:n/rounds/:k/interdiff?from=j` shows what changed between rounds, even after a force-push. - **Conversation:** `GET`/`POST …/issues/:n/comments` with the PR's number. - **Reviews:** `POST …/pulls/:n/reviews` with `event` `APPROVE`, `REQUEST_CHANGES` or `COMMENT`, a `body`, and line comments `[{path, line, side?, body}]` anchored to the current round; a line comment must name a path the round changed and a line inside its hunks (else 422). Reply in a thread with `POST …/pulls/:n/comments {"body","in_reply_to":}`; new threads start in a review. `GET …/pulls/:n/reviews` and `…/pulls/:n/comments` (each with `round`, `outdated`, `in_reply_to_id`). You cannot approve your own PR (422) — but a PR an agent task opened is the agent's work, so the person who started the task may approve or request changes; such reviews, and an agent's own, have `counts_as_required: false`. - **Merge:** `GET …/pulls/:n/mergeability` → `{mergeable, mergeable_state, conflicts, base_sha, head_sha, checks}`, then `PUT …/pulls/:n/merge {"merge_method":"merge"|"squash","sha":""}` (also `commit_title`, `commit_message`; nothing else). Needs write and a person (not a task token). `409` if the head or base moved; `405` with `conflicts` on a conflict, and for a draft or a closed or merged PR. Rebase merges are not supported yet. - **A merge is a push:** the base gets a `push` event (actor: whoever merged), `pushed_at` moves, and CI runs on the merge commit. `Fixes #n` in the PR body closes issue n. A squash commit is authored by the PR's author and committed by the merger; its message lists the PR's commits, with `Co-authored-by:` for other authors and `Gitober-Task: ` for a task's PR. - Task tokens may open, update and `COMMENT`; they never approve or merge (403). - Events: `pr.opened`, `pr.updated`, `pr.reviewed`, `pr.merged`, `pr.closed`, `pr.reopened`; `pr.opened` and `pr.updated` name `head_repo`. ## Agent tasks and `gfa_` tokens A person with write access starts a task and hands its token to an agent. The token acts as that person **on that one repo only** (every other repo is a 404), never as admin, and pushes only to the task's refs. It cannot create tokens, tasks or repos. ```sh curl -s -H "$H" -H "$J" -d '{"title":"fix add","ttl_hours":2}' $API/repos/ada/demo/agent-tasks # 201 {"id":"","status":"running","ref_globs":["refs/heads/agent//**", # "refs/gitober/scratch//**"],"expires_at":"…","token":"gfa_…"} ``` `ttl_hours` is 1 to 6 (default 2). With the task token: ```sh GFA=gfa_...; TASK= git clone "http://x-access-token:$GFA@${GITOBER_URL#http://}/ada/demo.git" && cd demo git push origin HEAD:refs/gitober/scratch/$TASK/attempt-1 # an attempt: a try run git push origin HEAD:refs/heads/agent/$TASK/fix # a branch for review git push origin HEAD:main # ! [remote rejected] … -> main (outside this task's refs) ``` - **Scratch refs** (`refs/gitober/scratch//...`) are for attempts: push as many as you like, several in one push. Each starts a **try run** (below). Delete one with `git push origin :refs/gitober/scratch/$TASK/attempt-1`. They are not collected automatically yet. - **Agent branches** (`refs/heads/agent//...`) behave like any branch. - **Protected paths:** a task cannot push changes to `.github/workflows/`, `.gitober/`, `CODEOWNERS`, `.github/CODEOWNERS` or `docs/CODEOWNERS`: `! [remote rejected] … (changes .github/workflows; an agent task needs a person's approval first (approval action edit_workflow))`. Ask with an approval request (below) with action `edit_workflow` (or `edit_protected_paths`); once a person approves, the task may change them until it ends. Task pushes over 32 MiB are refused. - What a task does is marked: events carry `task_id` (CI runs its pushes started included), and issues, comments, PRs and reviews carry `performed_via_task`. - End the task when you are done: `curl -s -X DELETE -H "Authorization: Bearer $GFA" $API/repos/ada/demo/agent-tasks/$TASK` (`204`). The token stops working at once, its pending approvals are cancelled, and `task.ended` carries `{"reason":"ended"|"expired","refs":[{"ref","sha"}…]}`: the refs it left behind. List or read tasks with `GET …/agent-tasks[/]`. ## Best of N: attempts, try runs, one PR 1. Push each attempt to its own scratch ref (one push is fine). 2. Wait for `run.queued` to learn the run ids, then for each run's `run.completed` (see Events and CI). Each carries the conclusion and the first errors with file and line, so you can rank attempts without reading logs. 3. Or list them: `GET …/actions/runs?ref_prefix=refs/gitober/scratch/$TASK/`. 4. Open a PR from the winner: `{"title":"Fix add","head":"refs/gitober/scratch//attempt-2","body":"Fixes #1"}`. Pushing that scratch ref again adds a round. 5. Give the PR's `html_url` to the person who started the task: they review (they may approve their agent's PR) and merge, on the page or by API. Watch for `pr.merged`, then end the task. Its scratch refs stay until you delete them. On a public repo, people without write access see none of this work in progress, in the API or on the web: scratch refs, try runs (events, runs, jobs, logs, results) and approval events. ## Events: wait instead of polling Every repo has an ordered event log. Read it from a cursor; add `wait` to block until something matching arrives. No public endpoint or webhook is needed. ```sh CUR=$(curl -s -H "$H" "$API/repos/ada/demo/events?after=latest" | jq -r .cursor) # … push, or ask for an approval … then block up to 60 s for what you care about: curl -s -H "$H" "$API/repos/ada/demo/events?after=$CUR&types=run.completed&wait=60" # {"events":[{"cursor":"6","id":"…","type":"run.completed","actor":null,"task_id":null, # "subject":"","data":{…},"created_at":"…"}],"cursor":"7"} ``` - `after`: a cursor from a previous response, `0` (the default) for the beginning, or `latest`. Always continue from the returned `cursor`, even when `events` is empty. A cursor past the end is a 422 that says where to resume. - `types`: comma-separated, checked (a typo is a 422 listing the valid types). `subject`: one subject: a ref for `push`, a run id for `run.*`/`job.*`, an issue or PR number for `issue.*`/`pr.*`, an approval id for `approval.*`, a task id for `task.*`. - `wait`: 0 to 60 seconds. It returns as soon as **one** matching event exists; an empty `events` after a wait is a timeout. To collect several (all runs of a push), keep reading from the returned cursor. - `limit`: 1 to 100 (default 50). - Each event has `actor` (a login, or null for the system) and `task_id` (the agent task that caused it, or null). - Types: `push`, `run.queued`, `job.completed`, `run.completed`, `issue.opened`, `issue.edited`, `issue.closed`, `issue.reopened`, `issue.commented`, `pr.opened`, `pr.updated`, `pr.reviewed`, `pr.merged`, `pr.closed`, `pr.reopened`, `approval.requested`, `approval.answered`, `approval.cancelled`, `approval.expired`, `task.started`, `task.ended`. ## Approval requests: ask a person When you need something you should not decide alone (editing a workflow, spending more), ask. Anyone with write access, including a task token, can ask; only a person with admin on the repo, signed in to the web page, can answer. Tokens never answer. ```sh curl -s -H "Authorization: Bearer $GFA" -H "$J" \ -d '{"action":"edit_workflow","reason":"CI needs node 24","expires_in_minutes":30}' \ $API/repos/ada/demo/approvals # 201 {"id":"","state":"pending","task_id":"","expires_at":"…", # "html_url":"$GITOBER_URL/approvals/",…} ``` Give `html_url` to your person, then wait for the answer: ```sh curl -s -H "$H" "$API/repos/ada/demo/events?after=$CUR&types=approval.answered,approval.expired,approval.cancelled&subject=&wait=60" # approval.answered data: {"action":"edit_workflow","state":"approved","decision":"approve", # "comment":"ok, just this file","task_id":""} ``` - `action`: lowercase letters, digits and `. _ : -`, at most 64. `reason`: required, at most 2000. `details`: a JSON object, at most 8 KB. Expiry: `expires_in_minutes` (5 to 10080) or `expires_in_hours` (default 24 hours); an expired request emits `approval.expired`, and a wait is woken when the deadline passes. - The requester (the task, or the person who asked) can withdraw: `POST …/approvals//cancel` → `approval.cancelled` (`reason: withdrawn`). Ending a task cancels its pending requests (`reason: task_ended`). A cancelled request has `cancelled_at`; `answered_at` is set only when a person answered. - `GET …/approvals?state=all|pending|approved|denied|expired|cancelled` and `GET …/approvals/` need write access to the repo. - `edit_workflow` and `edit_protected_paths` are enforced (above); other actions are a record and a signal, not a lock. - A person answers on the page, or with a browser session: sign in with `POST $GITOBER_URL/api/auth/sign-in/username {"username","password"}` (keep the cookie), then `POST …/approvals//answer {"decision":"approve"|"deny","comment"}` with that cookie, `Content-Type: application/json` and an `Origin` of the instance. ## CI Workflows are GitHub Actions YAML in `.github/workflows/`, run by a stock Gitea Runner. `on: push` with branch and tag filters, jobs, `needs` and `actions/checkout` work. Not yet: matrix, reusable workflows, secrets, cache, artifacts, re-running a job. An instance without a registered runner queues runs that never start. A push to a scratch ref starts a **try run** (`try_run: true`): every workflow with a `push` trigger runs, whatever its branch filters say, with no notifications. Wait for results instead of polling: ```sh CUR=$(curl -s -H "$H" "$API/repos/ada/demo/events?after=latest" | jq -r .cursor) git push origin HEAD:refs/gitober/scratch/$TASK/attempt-1 curl -s -H "$H" "$API/repos/ada/demo/events?after=$CUR&types=run.completed&wait=60" # data: {"run_id":"…","run_number":1,"workflow":"…","ref":"…","sha":"…","try_run":true, # "status":"completed","conclusion":"failure", # "jobs":[{"job_id":"…","job_key":"test","conclusion":"failure", # "log_path":"/api/v3/repos/ada/demo/actions/jobs//logs"}], # "results":{"counts":{"error":2,"warning":0,"notice":0},"errors":[{"source":"workflow_command", # "file":"add.js","line":1,"message":"…","step_name":"Test","log_line":102},…]}, # "results_path":"…"} ``` - One push can start several runs (one per workflow and pushed ref); each has its own `run.queued` and `run.completed` with the run id as subject. Merging a PR counts as a push to its base. Runs a task's push started carry its `task_id`, in events and REST. - `status` is `queued`, `in_progress` or `completed`; `conclusion` is `success`, `failure`, `cancelled` or `skipped`, null until completed, in events and REST alike. Skipped jobs also emit `job.completed`. - Ids: `run_id` and `job_id` are UUIDs; `job_key` is the job's key in the YAML. Read runs, jobs, logs and results; every endpoint needs read access (else 404): - `GET $API/repos/:owner/:repo/actions/runs`: filters `branch` or `ref` (a full ref), `ref_prefix` (a task's attempts), `head_sha`, `status` (a status or a conclusion), `try_run=true|false`, with `page`/`per_page`. Returns `{total_count, workflow_runs}`; a run has `id`, `run_number`, `name`, `path`, `status`, `conclusion`, `head_branch`, `head_sha`, `ref`, `try_run`, `html_url`, `jobs_url`, `results_url`. - `GET …/actions/runs/:run_id`, `…/actions/runs/:run_id/jobs` (each job with `job_key`, `conclusion` and numbered `steps`, each step with its `log_range`), `…/actions/jobs/:job_id` and `…/actions/jobs/:job_id/logs` (`text/plain`). - `GET …/actions/runs/:run_id/results`: `{total_count, counts, results}`. A result is `{level, source, message, job_id, job_key, job_name}` plus, when known, `file`, `line`, `col`, `title`, `step_number`, `step_name`, `log_line`, `log_range`. `source` is `workflow_command` (a step printed `::error file=…,line=…::message`), `step` (a step failed; one record each even without a message) or `workflow` (the workflow could not be planned; `file` is the workflow's path). - `log_line` and `log_range` count log rows from 0; `log_range.end` is exclusive. - Make your tools print `::error file=,line=::` on failures (for `node --test`, a small reporter that does) and the file and line come back in results. A `title=` survives as a separate field only when the step's script contains the command literally; printed at run time, it becomes a `Title: ` prefix of `message`. ## Not there yet The MCP endpoint and the `gitober` CLI; labels, assignees and milestones; collaborator invitations; organizations and teams; required reviews and checks before merge; rebase merges; updating a PR branch from its base on the server; deleting the head branch on merge; CI for a fork's PR in the parent repo (`pull_request` triggers; a fork's own CI shows as its checks); re-running a CI job; JUnit test reports in results; search; webhooks; renaming or deleting repos. ## Limits - Tokens: personal tokens live 1 to 366 days (default 30); task tokens 1 to 6 hours. - 1 GB per repository, 32 MB per file, 100 MB per push (Free/Pro zones), HTTPS only. - Diffs, mergeability and merges are computed in the Worker: a PR whose objects exceed 32 MiB, or whose computation runs past its time budget, gets a 422 naming the limit. - `git push --atomic` and push options (`git push -o`) are refused, as by Cloudflare Artifacts underneath; git reports only "the remote end hung up unexpectedly". Push refs without them, and check each ref's result. - Hosted runners, when available: Linux amd64, at most 4 vCPU, 12 GiB RAM, 20 GB disk.