Hosted Projects & the Agent Plane
A how-to, not a spec. Normative detail lives in services.md, mcp.md, and bundle-format.md § References.
A hosted project lives in your arkaik account rather than in one browser. It is the same graph, the same format, and the same tools — but the app and a coding agent in any repository can both read and write it, and pull requests can move its acceptances as work ships.
Local-first projects are unchanged. Nothing here is required to use arkaik.
The two links (read this first)
The word "link" means two different things, and you generally want both. They are independent — either works without the other.
| Where | What it connects | |
|---|---|---|
arkaik link | your terminal, in a code repo | that repo → a hosted project, so an agent working there can read and edit the map |
| Repos button | the app, on a hosted project card | a repo — or one folder of it — → this project's acceptances, so pull requests there can move statuses |
The first is the agent plane. The second is the PR automation. Set up whichever you need.
1. Create a token
/settings/tokens → Create token. It is shown once; it is stored hashed
and cannot be recovered.
export ARKAIK_TOKEN=ark_...
Scopes default to graph:read + graph:write. Token management itself is
session-only, so a leaked token can never mint another or widen its own scopes.
2. Get a hosted project
On /projects, either create a project while signed in, or take a browser-held
one and click Move to account.
Moving is a copy: the local original stays where it is and the hosted copy
gets a new server-owned id (prj_…). Nothing is lost if you change your mind.
Hosted projects carry an In your account badge.
3. Point an agent at it
In the repository where you write code:
npx arkaik link --list # projects this token can reach
npx arkaik link --project prj_xxxxx # writes docs/arkaik/arkaik.json
docs/arkaik/arkaik.json holds the project id and origin. It is safe to
commit; the token is only ever read from $ARKAIK_TOKEN, because a credential
in a repo file is a credential in a git history.
Then point any MCP host at the server:
npx -y arkaik-mcp # picks up the link file automatically
The tool catalog is identical to repo-bundle mode — list_nodes,
get_node, create_node and the rest behave the same whether the graph is a
file or an account. Resolution order, if you need to override: --bundle always
wins; otherwise --project → $ARKAIK_PROJECT → the link file.
A linked repo with no token exits non-zero rather than quietly serving a stale local bundle — a silent fallback is the failure an agent cannot notice.
This is the whole agent setup. The rest of this page is the PR automation, which is optional.
4. Set up the GitHub App (once per deployment)
Only needed for PR-driven status changes, and only once for your whole account.
- New GitHub App
- Webhook URL:
https://<your-origin>/api/github/webhook - Secret: generate one (
openssl rand -hex 32) — required - Repository permissions → Pull requests: Read-only
- Subscribe to events → Pull request
- Leave Callback URL, Setup URL, and device flow blank; they are unused.
- Webhook URL:
- Set
GITHUB_WEBHOOK_SECRETto the same value in your host's environment, and redeploy — most platforms do not apply env changes to a running deployment. - Install the App on the repositories you care about.
A private key, only if you use path-scoped links
Everything above resolves from the delivery payload, so a deployment whose
repository links each cover a whole repository never calls the GitHub API back
and needs no key. Path-scoped links do: deciding whether a pull request
belongs to apps/ios or apps/webapp means reading which files it changed, and
that list is not in the payload. See Monorepos.
If you want them: Generate a private key in the App's settings, then set
both GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY (the .pem contents,
verbatim, -----BEGIN and -----END lines included) and redeploy.
-
It has to be that key, and it has to be RSA. A GitHub App JWT is RS256, so an Ed25519 or EC key — what a TLS or SSH key pasted into the wrong secret looks like — cannot sign one. Both parse as valid PEM files, so arkaik checks the key type and reports it by name before signing anything, rather than crashing mid-delivery on one and sending a signature GitHub answers
401to on the other. -
No permission change and no reinstall. The pull-request files endpoint,
GET /repos/{owner}/{repo}/pulls/{number}/files, is a sub-resource of a pull request and needs Pull requests: Read — the permission step 1 already asks for. arkaik reads only each entry'sfilenameandprevious_filename, never a blob, so no Contents permission is involved. It does not take that on trust either: the token exchange asks forpull_requests: readexplicitly and checks the response granted it, so a misconfigured App is reported by name. -
The App has to be installed, not merely configured as a repository webhook — the changed-files call is authenticated as the installation, whose id arrives in the delivery. A plain repository webhook carries none, and the delivery says so instead of guessing a platform.
-
If your host can only store single-line secrets, literal
\nescapes in the key are accepted and converted. A base64-encoded copy of the whole file is not, and is refused with a message saying so rather than a crypto stack trace. -
Missing key, missing App id, or no installation id are all deterministic: the delivery reports the exact variable to set and moves nothing. No retry can set an environment variable, so it is not retried. A GitHub 5xx, a rate limit or a network failure is the opposite — the delivery fails with a 500 so GitHub's redelivery can redo it, and no partial write is left behind, because the file list is read before anything is written.
Checking it works
An unsigned request to the endpoint is a precise diagnostic:
curl -i -X POST https://<your-origin>/api/github/webhook \
-H 'content-type: application/json' -d '{}'
| Response | Meaning |
|---|---|
503 webhook_not_configured | the secret is not set (or you have not redeployed) |
401 invalid_signature | correct — the secret is live and unsigned requests are refused |
Then in the App's Advanced → Recent Deliveries, redeliver the ping. A
202 proves the signature matched, not merely that the endpoint is
reachable — GitHub signs pings too.
Verification refuses everything when no secret is configured, rather than degrading to accepting unauthenticated writes.
5. Link repositories to the project
On the hosted project's card → Repos → enter owner/name, leave the path
box empty, and choose the platform that repository builds for → Link.
The path box is for monorepos, and only for them: a link is identified by
(repository, path), so one repository can be linked several times — apps/ios
as iOS, apps/webapp as Web. An empty path is the whole repository, which is
what every link made before this existed already means. See
Monorepos.
The platform is the point: a PR merged in an iOS repository marks only iOS
shipped, with no per-PR annotation and no way to forget. The remaining platforms
then surface as a genuine parity gap on /acceptances, rather than false
parity. Choose All platforms for a repository that genuinely serves every
platform at once: its PRs then move the acceptance's base status, which marks
every platform that has no per-platform status of its own as shipped —
unless a PR names a platform itself (see step 7). A
platform already pinned in platformStatuses keeps what it was pinned to; the
base status is only ever the fallback, which is exactly why moving it reaches so
far.
If a PR in that iOS repository mentions an acceptance that does not list iOS
— a web-only acceptance, say — the delivery attaches no ref and promotes
nothing, and says so in its warnings. (If that acceptance already carries a
ref for the pull request, the ref keeps being mirrored — a stale status helps
nobody — but that delivery promotes nothing from it. See the refusal note in
step 7 for what "that delivery" does and does not cover.)
It does not fall back to an
unscoped ref: an unscoped ref moves the base status, and would mark that
acceptance shipped on web, which is the opposite of what linking the repo to
iOS asked for. A missing claim is recoverable; a wrong one looks like success.
"All platforms" is a claim, not a shrug. The base status is what every platform without its own entry falls back to, so moving it says every one of those shipped — and when nothing is pinned, that is all of them and
/acceptancesshows no parity gap. If a repo does not really ship all platforms together, link each app's folder to its own platform — see Monorepos.
6. Opt the project in
Promotion is opt-in, per project. Without it, a PR's status is mirrored onto the acceptance's ref but no status moves — that is the format's default (bundle-format.md § References), and it is deliberate: automatic promotion should be a recorded decision, not an implicit behaviour of the tooling.
There is no UI for this yet. Open the project → Journey map → Raw, and
add ref_policy to the project's metadata:
"project": {
"id": "…",
"title": "…",
"metadata": { "ref_policy": true }
}
true selects the defaults:
| PR state | Acceptance status |
|---|---|
| opened / reopened | development |
| merged | live |
| closed without merging | unchanged |
To choose your own mapping, give an object instead. null means "recognised,
moves nothing":
"ref_policy": { "github-pr": { "open": "development", "merged": "releasing", "closed": null } }
7. Ship something
Open a pull request in a linked repository whose title or body mentions an acceptance id, for example:
Implements AC-guest-checkout
- on open → that acceptance moves to
development - on merge →
live, on the platform that repository builds for — or, in a monorepo, the platform of the folder the PR touched
Naming the platform in the mention
Add @web, @ios or @android to say what shipped, regardless of what the
repository link declares or which folder the PR touched:
Implements AC-guest-checkout@ios
- an explicit
@platformalways wins. The whole precedence, once: the@platformin the mention, then the path-scoped link the pull request's changed files landed in, then the repository's own link. A bare mention in an iOS-linked repo still means iOS, but@webthere means web; - one PR can name several —
AC-guest-checkout@ios and AC-guest-checkout@androidmarks both platforms shipped and leaves the third as a genuine parity gap; - writing both
AC-guest-checkoutandAC-guest-checkout@iosin one PR means iOS only — the explicit scope absorbs the bare mention rather than also moving the base status; - an unknown platform is reported, not guessed —
AC-guest-checkout@windowsmoves nothing at all and says so in the delivery response, because asking for a platform and quietly getting the repo default is worse than doing nothing. The suffix has to match a platform name exactly:@android-tv,@android_tv,@ios.tv,@ios/ipadand@ios2are all reported as written, never read asandroidorios. Ordinary prose around the mention is fine —AC-x@ios.,(AC-x@ios)and`AC-x@ios`all mean iOS; \@works too — writingAC-guest-checkout\@iosto stop GitHub rendering@iosas a user mention still scopes to iOS, rather than silently becoming an unscoped mention;- naming no platform is the biggest claim, not the smallest — an unscoped
mention moves the acceptance's base status, which every platform without its
own entry falls back to. On a three-platform acceptance with nothing pinned
that marks all three delivered and leaves no parity gap. On a partly shipped
one it is worse, not better:
{web: "live"}with a base ofbacklogis a real parity gap, and moving the base tolivemakes iOS and Android inherit it, so the gap disappears. Either way the delivery response names the platforms it is about to mark, one line per acceptance.
An agent can also attach the ref explicitly with update_node rather than
relying on a mention.
Guards that keep this honest:
- archived acceptances are never resurrected by a stale PR;
- an acceptance already at the target status is skipped, so re-runs and redeliveries are no-ops;
- a platform that was asked for and cannot be honoured is refused, never
downgraded — this is the rule the other platform guards are instances of.
An unscoped ref moves the base status, which marks every platform without a
per-platform status of its own shipped, so falling back to one whenever a
scope cannot be resolved would make the largest possible claim at the moment
arkaik is least sure. It does not:
- a
@platformyou asked for explicitly stays on the ref, and the promotion is reported and refused, rather than moving the base status; - a repository link naming a platform the acceptance does not list
attaches no ref at all and promotes nothing, and the
warningsline names both the link's platform and the acceptance's actual ones; - a path-scoped link is a standing request to scope every pull request, so
when that request cannot be answered — the pull request touched none of the
linked paths and no whole-repository link exists, or the changed files could
not be read at all — nothing is attached and nothing is promoted. The
warningsline quotes every configured prefix, so a typo'dapps/i0sis visible in the one place you can see it; - a refusal covers promotion, not just attachment. If the acceptance
already carries a ref for that pull request, the ref keeps being mirrored —
a body edit must not freeze it at a stale status — but that delivery
promotes nothing from it, on the first delivery or on any redelivery. It is
scoped to the delivery, not a quarantine: the ref stays attached and stays
promotable, so
arkaik sync --promote— which computes promotions over the whole bundle, with no notion of a delivery — can still move a status from it. If you do not want that, remove the ref;
- a
- an unknown
@platformsuffix is reported, never treated as a bare mention — and if the same PR also mentions that acceptance bare (AC-xin the title,AC-x@ios-tabletin the body), the bare mention is not used as the typo's fallback either. The refusal belongs to the acceptance id, not to what else the PR happens to say about it: a PR whose only reference isAC-x@ios-tabletrefuses in exactly the same way, rather than falling through to the repository link and promoting whatever it declares. Nothing is attached, nothing moves, and any ref the PR already left on that acceptance is not promoted from on that delivery. Fix the suffix and edit the PR: an edit re-delivers; - every promotion lands as a normal
node.status_changedjournal event withgithub-appas the actor — the history says what acted.
If nothing happens, Advanced → Recent Deliveries shows the response body,
which names what was applied, why it was skipped, and any warnings — that is
where an unknown platform suffix is quoted back to you, where an unscoped
promotion tells you which platforms it is about to mark shipped, and where a
refused scope names what it refused and why.
A refusal plans no promotion for that acceptance, and attaches no new ref.
It is not always zero ops: if the acceptance already carries a ref for that pull
request, the mirror refresh is still planned, so applied can be non-zero for a
delivery that moved no status at all. When the refused acceptance is the only
thing the pull request named and it carried no ref yet, the outcome reads
{ "applied": 0, "skipped": "an acceptance was named, but no platform scope could be honoured", "warnings": ["…"] }
When the same pull request also moved another acceptance, applied is
non-zero and there is no skipped at all — the refusal then shows up only
in warnings, beside a delivery that otherwise looks like a plain success. So
warnings is the field to read, not skipped: a non-empty warnings means
something was not honoured whatever applied says.
The first thing to check there is the repository link itself: a delivery whose repository no project has linked answers
{ "status": "ok", "outcomes": [], "skipped": "no project has linked acme/webapp" }
which is almost always a typo in Repos, or a link that was never made.
Monorepos
A single repository that builds several platforms — apps/ios, apps/webapp,
apps/android under one repo — is linked once per app, with the folder that
app lives in:
| Repository | Path | Platform |
|---|---|---|
acme/pbbls | apps/ios | iOS |
acme/pbbls | apps/webapp | Web |
acme/pbbls | apps/android | Android |
Then a PR that mentions an acceptance and touches apps/ios/… marks iOS
shipped, with no suffix to write and nothing to remember. A PR touching both
apps/ios and apps/webapp marks both.
And it follows the PR as it changes. The folders a pull request touches are edited while it is open, so what the paths decided last time is not evidence this time. If a PR that touched both apps drops its webapp commits before merging, the web ref arkaik attached earlier is frozen: nothing about the pull request justifies it any more, so the delivery stops refreshing it and promotes nothing from it. It is not deleted — the acceptance still shows a ref recording that this PR once touched web, at the status it was last honestly mirrored to. It simply stops moving. The delivery response says which scope froze and why.
The exact rule, because the edges matter. For an acceptance the pull request
currently names, a ref is mirrored and promoted from while any live source
still names its platform: an @platform in the PR's current title or body, the
path-scoped links its current changed files land in, or — only when no
path-scoped link matched — the repository link. (Once a path-scoped link has
matched, the whole-repository link is not consulted: it is the fallback for
"anything I did not scope", and these files were scoped.) A platform no live
source names freezes. Four consequences worth knowing:
- freezing is not deletion, and it is not a quarantine. A frozen ref stops
being carried forward — most importantly, it is never upgraded to
merged— so it cannot become a standinglivepromotion thatarkaik sync --promotefires later. But if an earlier, truthful delivery had already mirrored it to a promotable state, it stays promotable out of band: freezing stops a ref moving, it does not undo a state already written. If you do not want that, remove the ref; - a
@platformyou edited out of the body is withdrawn. arkaik does not record which source attached a ref, so awebref you asked for by hand is indistinguishable from one inferred from a file list — and once the body stops saying@weband nothing else covers web, it freezes like any other. Editing the PR is how you withdraw a scope as well as how you add one; writing@webback starts it moving again on the next delivery; - nothing is ever frozen on a partial file list (see the truncation and time-budget notes below). A list arkaik could not finish reading can show a platform is there and can never show it is gone, so such a delivery mirrors and promotes exactly as it would have before path-scoped links existed. Incomplete evidence never narrows anything;
- an acceptance the PR stopped naming altogether is left alone. Its refs keep being mirrored and keep promoting; a body edit must not undo a promotion that already landed.
This needs the App's private key (step 4); whole-repository links do not, and are unaffected by it being absent.
How a path is matched:
- paths are repository-root-relative, as GitHub writes them.
/apps/ios/,./apps/iosandapps//iosare all normalised toapps/ioswhen saved, so one folder is one link and not three; a..segment is refused outright; - whole folders only.
apps/iosmatchesapps/ios/main.swiftand the fileapps/iositself, and neverapps/ios-legacy/main.swiftorapps/ios2/x.ts; - case-sensitively, because git paths are (repository names are not, and
are lowercased for you) —
Apps/iOSis a different path fromapps/ios, and a link written the wrong way round matches nothing at all; - every changed file counts, including deleted ones and both halves of a
rename: moving a file out of
apps/iosis a change toapps/ios; - the most specific link wins per file. With both
apps→ Web andapps/ios→ iOS, a file underapps/iosis iOS only. Adding a more specific link can therefore only ever narrow a claim, which is what refining a configuration should do; - a path-scoped link set to All platforms matches without naming a platform
— how you say "changes under
packages/sharedship everywhere" — so precedence falls through to the whole-repository link behind it, or to an unscoped ref if there is none. Re-read the box in step 5 before choosing it. If the only links a PR's files landed in are ones like this and the file list was partial, the delivery refuses instead of falling through: a page arkaik never read could well land in a link that does name a platform, and answering that with an unscoped ref is the largest claim in the system made from a blind spot;
What happens when the paths do not decide — precedence is mention, then path, then link:
- a
@platformin the PR still wins, and is now an override rather than the main event:AC-guest-checkout@webscopes to web whatever folder was touched; - anything outside every path falls back to a link for the same repository with an empty path, if you made one. That link is your stated answer to "everything I did not scope is this" — a root-level release PR, say;
- with no such fallback link, nothing moves, and the delivery response says which paths are configured and that none of them matched. It does not borrow a platform from anywhere: an unscoped ref would move the base status and mark every platform shipped, which is the largest claim in the system offered in answer to "I do not know";
- the same applies when the changed files cannot be read — no private key, no App installation, a revoked installation. That is reported with the exact variable or setting to fix, and no retry happens, because none would help. Transient failures (a GitHub 5xx, a rate limit, a dropped connection) are retried by GitHub instead, and nothing is written until the file list is in hand;
- a PR that changes more files than GitHub will list (its cap is 3000) is matched against the partial list. A platform found that way is real — a missing file can only remove a match, never invent one — so the delivery proceeds and warns. If nothing matched in a partial list, that is not evidence of anything and the delivery refuses instead; and nothing is frozen from a partial list either. The same "partial list" treatment applies if reading the pages takes too long, or if GitHub returns an entry arkaik cannot read. The delivery response names which of the three happened, rather than blaming the file cap for all of them;
- the time budget bounds the file-list read, and only that. Resolving the changed files — the token exchange and every page — has one 9-second budget, and a page is started only if a full request timeout still fits inside it, so that part cannot overrun a 10-second serverless limit. The database writes that follow are not inside it, so the function as a whole still can run long; what the budget buys is that a slow GitHub is not the thing that does it. Nothing is written before the file list is in hand, so a kill during the read leaves no partial state;
- a stored path arkaik cannot read — a
..segment, which the link form refuses but a restored or hand-edited row can still carry — refuses the whole repository's links for that project and quotes the offending value. It is not read as an empty path: an empty path means the whole repository, which would turn the one row nobody can interpret into the broadest claim in the configuration.
Nothing is fetched unless it is needed. No path-scoped link on the repository means no API call at all — that is every deployment that existed before this feature. Even with them, a PR that mentions no acceptance costs nothing: the vast majority of pull requests in a monorepo never touch this code path.
A PR whose mentions all carry an explicit @platform does cost one call, even
though the suffix alone decides the scope. The paths are one of the sources that
keep an existing ref moving, and a delivery that read nothing cannot tell a
withdrawn scope from an unread one — so it would freeze nothing and leave a
stale ref being refreshed to merged and promoted. One request per mentioning
delivery is the price of that; it is bounded, and its failure is reported rather
than retried.
The plain-@platform arrangement still works and is still supported — link the
monorepo with an empty path and "All platforms", and suffix every PR — but the
suffix is then load-bearing, and forgetting it makes the largest claim the
automation can make rather than the smallest. Path-scoped links exist so that
nobody has to be that careful.
Self-hosting (Inkognito)
A deployment that cannot install the arkaik GitHub App gets the same behaviour from the CLI:
arkaik sync --promote
It mirrors ref status from the GitHub API using a local token, then applies the
same computeRefPromotions the hosted App uses — one implementation, so the two
paths cannot disagree about what a merged PR means. Run it in CI on
pull_request and on pushes to your default branch.
What it does not do is read PR text: sync refreshes refs that already
exist, so neither a bare mention nor an @platform suffix attaches anything.
Repository links play no part either, path-scoped or not — they are how the
webhook decides which project and platform a delivery is about, and sync
scopes each promotion by the platform already on the ref. Attach the ref once
(by hand, or with update_node from an agent) and sync keeps it mirrored from
then on.
Known gaps
ref_policyhas no UI — step 6 requires the raw bundle editor.- The refs editor is read-only — a human links a PR by mentioning the acceptance id; attaching a ref by hand in the app is not possible yet.
- Hosted projects are online-only — local-first projects still work offline; hosted ones do not. Export is always available.
propose_idea/file_requestdo not work against hosted projects — the hosted write path has no journal-only operation yet. They are refused with an explicit message rather than silently dropped.- A refusal, a partial file list, and a freeze scope the delivery — not the
ref. A ref a webhook declined to promote from is still attached, and if an
earlier delivery had already mirrored it to a promotable state it is still
promotable, so
arkaik sync --promotecan move a status from it later. Freezing prevents that state ever being reached by a later delivery; it cannot unwrite one. Refs carry no record of the delivery that attached them, so there is nothing forsyncto filter on; closing this properly means either recording that on the ref (a bundle-format change) or removing the ref, and removing is what freezing deliberately replaced — it would need a claim that a platform is absent, which a partial file list can never support.