CLI and API parity
Date: 2026-08-21
Commits measured: 81e4c25eb5b5 (openagents/main, the forge) for the API; 5bd0061e4f6e in the openagents monorepo for packages/openagents-cli (package version 0.1.7)
Status: Stages 1, 2, and 7 shipped. Stage 1 added openagents api in
@openagentsinc/cli@0.2.1. Stage 7 added repository-scoped Projects V2 routes
on 2026-08-22. Stage 2 completed on 2026-08-23: the comment, label, milestone,
assignee, issue-label, and issue-assignee reads now accept an optional bearer.
Historical measurements below retain the original findings.
Question: Does the CLI only cover repository upload? What of the Issues and Projects API does it reach? Should that coverage be generated from an OpenAPI document instead of hand-written? What is the fastest honest path to managing issues and projects from a terminal?
Method: direct reading of lib/openagents_web/router.ex, every controller it routes to under /api/v3, the contexts behind them (lib/openagents/issues.ex, labels.ex, milestones.ex, projects.ex, repositories.ex), the auth plugs, lib/openagents_web/route_authority.ex, priv/api-contracts/repositories-v1.json and its controller and test, and docs/openagents-cli/; plus direct reading of all 21 source files in the openagents monorepo at packages/openagents-cli/src/ and its tests. The CLI lives in a different repository, so every CLI citation names it. Claims that neither repository can settle are in section 7 with the command that would settle them.
0. Summary
Update, 2026-08-22: The server now resolves Projects V2 through
/repos/:owner/:repo/projectsV2, applies the named repository's read and write policy to every project operation, and supports authenticated reads of private issues. Theinitial_repository!/0andinitial_path/0shortcuts no longer exist. The findings below describe the pre-remediation surface. The CLI guides now documentopenagents api, Issues and Projects recipes, and the absence of namedissueandprojectcommands. The stale claim thatrepo deleteand generic API access were unavailable is fixed.
At the measured commits, the owner was right: the CLI was repository-shaped and reached none of the Issues and Projects API.
At those commits, the router exposed 50 routes under /api/v3
(lib/openagents_web/router.ex:229, :253, :259). The CLI called 11 of
them: the authenticated user, repository create, list, view, delete, the two
import routes, import status, and the two device-authorization routes. The
other 39 routes had no CLI surface. The current openagents api command
now reaches them without adding named commands.
There is no OpenAPI document. What exists instead is a 99-line hand-written JSON artifact served at /api/contracts/repositories-v1.json (lib/openagents_web/router.ex:222). It lists endpoints as opaque "METHOD /path" strings, has no types, no request bodies, no response schemas, and no status codes. It covers 11 of the 50 routes. Nothing derives it from the router, and nothing verifies it against the router.
The drift story today is that nothing fails. The server's contract test asserts the document is served with the right headers and carries the right name and version, and nothing else (test/openagents_web/controllers/api_contract_controller_test.exs:4). The CLI's contract test hashes the copy it vendors itself and compares it to a constant in its own source (openagents monorepo, packages/openagents-cli/test/api-contract.test.ts:13) — it never fetches the server's copy. The two files are byte-identical today, both hashing to 5be86539258c…, and that is a coincidence maintained by hand. Rename a field and both test suites stay green; the failure surfaces in a user's terminal as a ContractError.
Three findings matter more than the raw endpoint count, because they mean the gap is not only "the CLI has no issue command":
- You can write an issue into a private repository and cannot read it back. Writes resolve the repository through
Repositories.get_writable_by_path!/3(lib/openagents_web/controllers/issue_controller.ex:18), which does not filter on visibility. Reads run through a pipeline with no authentication at all (router.ex:230) and resolve throughRepositories.get_public_by_path!/2andIssues.get_issue_by_path!/3, which requirevisibility == "public"(lib/openagents/repositories.ex:68,lib/openagents/issues.ex:159).POSTreturns201with the issue body; the followingGETreturns404. This is deliberate, not accidental —test/openagents_web/route_authority_test.exs:26asserts the read is:public_readand the write is:authenticated_api— but it makes terminal issue management impossible for private work regardless of what commands the CLI grows. - The list endpoints have no pagination and one filter.
IssueController.indexcallsIssues.list_issues/2withstateonly (issue_controller.ex:10), returning every matching issue in one unbounded array, whileIssues.list_issues_page/2— which already supports:label,:assignee,:milestone,:q, and:page— sits unused by the API (lib/openagents/issues.ex:40). - The whole
projectsV2surface is pinned to one repository.Projects.list_projects_by_owner/1andget_project_by_owner_and_number!/2both scope toRepositories.initial_repository!()(lib/openagents/projects.ex:26,:131), which is the constantOpenAgentsInc/openagents.com(lib/openagents/repositories.ex:22).ProjectController.createignores its own:ownerpath segment when choosing a repository and writes into that same one (lib/openagents_web/controllers/project_controller.ex:21).
On generation: do not generate the commands. Ship a generic openagents api passthrough first, hand-write the ten commands that carry the traffic, and only then invest in a derived contract document. Section 4 argues it. The short version is that a passthrough buys total endpoint coverage for roughly 200 lines and can never drift because it makes no claims, while generated command surfaces are worst exactly at the commands people use every day. Generating a client is defensible; generating a CLI is not.
The first two stages are small and independent: the passthrough command (CLI repository only, no server change), then moving the read routes behind optional bearer auth so a private repository's issues are readable by someone entitled to read them (this repository only, additive).
1. What the CLI covers today
1.1 The command tree
The CLI is 3,452 lines of Effect TypeScript across 21 files in packages/openagents-cli/src/ in the openagents monorepo. It registers two command groups and twelve leaf commands, wired at cli.ts:800:
| Group | Command | Source line (openagents monorepo, packages/openagents-cli/src/cli.ts) |
|---|---|---|
auth |
login (with --headless, --resume, --token-stdin) |
:183 |
auth |
token-stdin |
:144 |
auth |
status |
:79 |
auth |
logout |
:331 |
auth |
setup-git |
:369 |
auth |
git-credential (internal Git credential-helper protocol) |
:349 |
repo |
create |
:445 |
repo |
import |
:557 |
repo |
list |
:640 |
repo |
view |
:699 |
repo |
clone |
:719 |
repo |
delete |
:756 |
Four global flags apply to all of them — --profile, --api-url, --json, --no-color (cli.ts:23–:31) — and the root command describes itself as "Manage OpenAgents repositories" (cli.ts:34).
1.2 The endpoints it calls
Eleven, all of them in the repository, identity, and device-authorization families:
| Method and path | Called from (openagents monorepo) |
Reached by |
|---|---|---|
POST /api/v3/device/authorizations |
src/device-client.ts:52 |
auth login |
POST /api/v3/device/authorizations/token |
src/device-client.ts:83 |
auth login |
GET /api/v3/user |
src/repository-client.ts:363 |
auth status, repo import |
GET /api/v3/user/repos |
src/repository-client.ts:381 |
repo list |
POST /api/v3/user/repos |
src/repository-client.ts:333 |
repo create |
POST /api/v3/orgs/{org}/repos |
src/repository-client.ts:333 |
repo create |
POST /api/v3/user/repos/imports |
src/repository-client.ts:524 |
repo import |
POST /api/v3/orgs/{org}/repos/imports |
src/repository-client.ts:525 |
repo import |
GET /api/v3/repos/{owner}/{repo} |
src/repository-client.ts:278, :399 |
repo view, repo clone, provisioning poll |
DELETE /api/v3/repos/{owner}/{repo} |
src/repository-client.ts:413 |
repo delete |
GET /api/v3/repository-imports/{id} |
src/repository-client.ts:424 |
repo import wait loop |
Git data transfer is not in this list because it is not API traffic. repo clone shells out to git (src/git-runner.ts), and auth setup-git installs this binary as a Git credential helper so ordinary git push and git fetch authenticate against the smart HTTP transport at router.ex:203.
1.3 The contract layer, and what it actually checks
src/api-contract.ts is 105 lines of Effect Schema definitions for exactly four response shapes — the authenticated user, a repository, a repository import, and a loose error envelope (api-contract.ts:14, :51, :64, :93). Every response is decoded through it, and a decode failure becomes a ContractError naming the operation (src/repository-client.ts:227).
The transport underneath is deliberately thin: one request function that takes an origin, a method, a path string, an optional bearer token, and an optional JSON body (src/api-transport.ts:82). Paths are built by string interpolation at each call site. There is no route table, no path type, and no generated surface — adding an endpoint means adding a method to repository-client.ts, a schema to api-contract.ts, and a command to cli.ts.
The contract artifact is a separate thing from the contract schemas, and this is where the drift story lives. api-contract.ts:5 pins a SHA-256, and test/api-contract.test.ts:13 reads packages/openagents-cli/contracts/repositories-v1.json — a copy vendored inside the CLI package and shipped in its npm tarball (package.json, files) — hashes it, and asserts it equals that constant. The test never contacts the server. It detects an unannounced edit to the vendored copy. It cannot detect that the server changed.
Both copies currently hash to 5be86539258c38d5249887ff2628680b1f90c93909d6e83e86a836d0250ce79f, so the two repositories agree today. Nothing enforces that they keep agreeing.
1.4 Verdict on the owner's belief
Confirmed, with one refinement. "Repo upload stuff" understates it slightly — the CLI also does device-flow authentication, credential storage, Git credential-helper installation, repository listing, viewing, cloning, and deletion, and it waits on durable provisioning and import state machines. But on the substance the belief is exactly right: the CLI has no issue, comment, label, milestone, assignee, or project command, and calls no endpoint in those families. Its own reference documentation states the boundary (docs/openagents-cli/command-reference.md:3, :171) and its README.md in the monorepo has sections only for install, API selection, sign-in, and repositories.
One documentation defect found at the measured commit remains relevant as
evidence for section 4: the command reference said the release did not provide
repo delete while the same file documented the command. The 2026-08-22
documentation reconciliation fixed that contradiction and added the missing
generic API reference.
2. What the API offers
Fifty routes under /api/v3, in three scopes distinguished by pipeline.
2.1 Authentication, in one paragraph
Three pipelines serve /api/v3. :api (router.ex:25) runs accepts ["json"] and nothing else — no authentication whatsoever; a bearer token sent to one of these routes is ignored and conn.assigns.current_user is never set. :forge_write_api (router.ex:38) requires Authorization: Bearer oa_pat_… carrying scope forge:write, or it halts with 401 {"error":"invalid_api_token"} (lib/openagents_web/plugs/api_token_auth.ex:10). :optional_forge_api (router.ex:43) accepts the same token but tolerates its absence, which is what lets GET /api/v3/repos/:owner/:repo widen from public repositories to the caller's private ones (lib/openagents_web/controllers/repository_controller.ex:64). There is exactly one scope in the whole system: @allowed_scopes ["forge:write"] (lib/openagents/api_tokens.ex:12). A token that can file an issue can also delete a repository.
2.2 Reads — no authentication, public repositories only
All sixteen are in the :api scope at router.ex:229.
| Method and path | Controller action | Line | GitHub-compatible path? |
|---|---|---|---|
GET /repos/:owner/:repo/issues |
IssueController.index |
:235 |
yes |
GET /repos/:owner/:repo/issues/:issue_number |
IssueController.show |
:236 |
yes |
GET /repos/:owner/:repo/issues/:issue_number/comments |
CommentController.index |
:237 |
yes |
GET /repos/:owner/:repo/issues/comments/:id |
CommentController.show |
:238 |
yes |
GET /repos/:owner/:repo/issues/:issue_number/labels |
IssueLabelController.index |
:239 |
yes |
GET /repos/:owner/:repo/issues/:issue_number/assignees |
IssueAssigneeController.index |
:240 |
no — GitHub carries assignees inside the issue object |
GET /repos/:owner/:repo/labels |
LabelController.index |
:241 |
yes |
GET /repos/:owner/:repo/labels/:name |
LabelController.show |
:242 |
yes |
GET /repos/:owner/:repo/milestones |
MilestoneController.index |
:243 |
yes |
GET /repos/:owner/:repo/milestones/:milestone_number |
MilestoneController.show |
:244 |
yes |
GET /repos/:owner/:repo/assignees |
AssigneeController.index |
:245 |
yes |
GET /repos/:owner/:repo/assignees/:assignee |
AssigneeController.show |
:246 |
path yes, body no — GitHub answers 204/404 with no body |
GET /users/:username/projectsV2 |
ProjectController.index |
:247 |
no — ProjectsV2 is GraphQL-only on GitHub |
GET /users/:username/projectsV2/:project_number |
ProjectController.show |
:248 |
no |
GET /users/:username/projectsV2/:project_number/items |
ProjectController.items |
:249 |
no |
GET /users/:username/projectsV2/:project_number/fields |
ProjectController.fields |
:250 |
no |
Every issue, label, milestone, and assignee read is gated in the controller by Repositories.get_public_by_path!/2 or an equivalent visibility == "public" clause, so a private repository is indistinguishable from a missing one. The four project reads apply no visibility predicate at all (project_controller.ex:8, :41, :53, :144); they are constrained only by being pinned to the initial repository.
2.3 Writes — bearer token with forge:write, repository membership required
All twenty-three are in the :forge_write_api scope at router.ex:259, alongside the nine repository and import routes the CLI already uses.
| Method and path | Controller action | Line | GitHub-compatible path? |
|---|---|---|---|
POST /repos/:owner/:repo/issues |
IssueController.create |
:271 |
yes |
PUT /repos/:owner/:repo/issues/:issue_number |
IssueController.update |
:272 |
no — GitHub uses PATCH only |
PATCH /repos/:owner/:repo/issues/:issue_number |
IssueController.update |
:273 |
yes |
POST /repos/:owner/:repo/issues/:issue_number/comments |
CommentController.create |
:274 |
yes |
PUT /repos/:owner/:repo/issues/comments/:id |
CommentController.update |
:275 |
no |
PATCH /repos/:owner/:repo/issues/comments/:id |
CommentController.update |
:276 |
yes |
DELETE /repos/:owner/:repo/issues/comments/:id |
CommentController.delete |
:277 |
yes |
POST /repos/:owner/:repo/issues/:issue_number/labels |
IssueLabelController.create |
:278 |
yes |
DELETE /repos/:owner/:repo/issues/:issue_number/labels/:name |
IssueLabelController.delete |
:279 |
yes |
POST /repos/:owner/:repo/issues/:issue_number/assignees |
IssueAssigneeController.create |
:280 |
yes |
DELETE /repos/:owner/:repo/issues/:issue_number/assignees |
IssueAssigneeController.delete |
:281 |
yes |
POST /repos/:owner/:repo/labels |
LabelController.create |
:282 |
yes |
PUT /repos/:owner/:repo/labels/:name |
LabelController.update |
:283 |
no |
PATCH /repos/:owner/:repo/labels/:name |
LabelController.update |
:284 |
yes |
DELETE /repos/:owner/:repo/labels/:name |
LabelController.delete |
:285 |
yes |
POST /repos/:owner/:repo/milestones |
MilestoneController.create |
:286 |
yes |
PUT /repos/:owner/:repo/milestones/:milestone_number |
MilestoneController.update |
:287 |
no |
PATCH /repos/:owner/:repo/milestones/:milestone_number |
MilestoneController.update |
:288 |
yes |
DELETE /repos/:owner/:repo/milestones/:milestone_number |
MilestoneController.delete |
:289 |
yes |
POST /:owner/projectsV2 |
ProjectController.create |
:290 |
no, and inconsistent with its own reads |
POST /users/:username/projectsV2/:project_number/items |
ProjectController.create_item |
:291 |
no |
POST /users/:username/projectsV2/:project_number/fields |
ProjectController.create_field |
:292 |
no |
PATCH /users/:username/projectsV2/:project_number/items/:item_id |
ProjectController.update_item |
:294 |
no |
Note router.ex:290: project creation lives at /api/v3/:owner/projectsV2 while every project read and every other project write lives under /api/v3/users/:username/projectsV2. A generated client would faithfully reproduce that inconsistency; a human writing a command would notice it.
No routed action is a stub. Every one of the fifty reaches a real context function.
There is no search endpoint, no GET /api/v3/users/:username, no GET /api/v3/orgs/:org, no issue events, reactions, or timeline, and no /api/v3/meta.
2.4 Where "GitHub-compatible" stops being true
The paths are largely GitHub-shaped. The bodies are not, in one systematic way that matters for any plan involving generated clients: every list response is wrapped in a named envelope, where GitHub returns a bare array.
GET /repos/:owner/:repo/issues returns {"issues": [...]} (lib/openagents_web/controllers/issue_json.ex:6), and the same pattern holds for labels (label_json.ex:6), milestones (milestone_json.ex:6), comments (comment_json.ex:6), and projects (project_json.ex:6). Repository lists use a third shape again, {"repositories": [...], "next_cursor": …} (priv/api-contracts/repositories-v1.json:56 describes it; the CLI decodes it at packages/openagents-cli/src/api-contract.ts:56 in the monorepo).
Two further deviations worth recording:
- Two error dialects. The repository endpoints emit
{code, message, request_id}with a fixed vocabulary of twenty stable codes (priv/api-contracts/repositories-v1.json:74), and the CLI decodes exactly that (packages/openagents-cli/src/repository-client.ts:157in the monorepo). The issues endpoints emit{"message": "Not Found"}for 404 and{"errors": {field => [messages]}}for 422 (issue_controller.ex:44,issue_json.ex:14). The CLI's decoder tolerates the 404 because it reads amessagekey, and degrades to the genericThe API returned HTTP 422.for the validation case, discarding the field errors. - Hardcoded origin in generated URLs.
issue_json.ex:38buildshtml_urlandurlfrom the literalhttps://openagents.com, so a staging response points at production.
The practical consequence: you cannot point GitHub's own OpenAPI description, or a client generated from it, at this server and expect it to work. The surface is GitHub-inspired, not GitHub-compatible, and any generated client has to be generated from this repository's own description of itself.
3. The gap
Thirty-nine of fifty routes have no CLI surface. Not "partial" — zero. Every row in section 2.2 and section 2.3 is uncovered.
3.1 Ranked by what a person managing issues from a terminal reaches for first
| Rank | What you want to type | Endpoints behind it | Blocked by anything beyond the missing command? |
|---|---|---|---|
| 1 | openagents issue list |
GET .../issues (router.ex:235) |
Yes — public repositories only, unpaginated, state is the only filter |
| 2 | openagents issue view 41 |
GET .../issues/:n, GET .../issues/:n/comments (:236, :237) |
Yes — public repositories only |
| 3 | openagents issue create |
POST .../issues (:271) |
No |
| 4 | openagents issue close / reopen / edit |
PATCH .../issues/:n (:273) |
No — state is castable (lib/openagents/issues/issue.ex:32) and covered by test/openagents_web/controllers/issue_controller_test.exs:92 |
| 5 | openagents issue comment 41 |
POST .../issues/:n/comments (:274) |
No |
| 6 | openagents issue label add / remove |
POST, DELETE .../issues/:n/labels (:278, :279) |
No |
| 7 | openagents issue assign / unassign |
POST, DELETE .../issues/:n/assignees (:280, :281) |
No |
| 8 | openagents label list / create / edit / delete |
router.ex:241, :242, :282, :284, :285 |
Reads are public-only |
| 9 | openagents milestone CRUD |
router.ex:243, :244, :286, :288, :289 |
Reads are public-only |
| 10 | openagents project list / view / item add / item move |
router.ex:247–:250, :290–:296 |
Yes — the whole surface is pinned to one repository |
Ranks 3 through 7 are pure CLI work: the endpoints exist, take a bearer token, and enforce membership correctly. Ranks 1, 2, 8, and 9 need a server change first or they only work on public repositories. Rank 10 needs a server change that is larger than a command.
3.2 Three gaps that no CLI command can close
The private-repository read asymmetry. This is the one that matters most, because it defeats the use case rather than delaying it. Consider a private repository you own:
POST /api/v3/repos/you/private/issues → 201, issue #1 in the body
GET /api/v3/repos/you/private/issues/1 → 404 {"message":"Not Found"}
The write path resolves through Repositories.get_writable_by_path!/3, which joins on membership and filters on lifecycle_state but never on visibility (lib/openagents/repositories.ex:111). The read path runs in a pipeline that discards the bearer token entirely (router.ex:230) and then filters visibility == "public" in the query (lib/openagents/issues.ex:159, lib/openagents/repositories.ex:68). The repository already has the mechanism to fix this — Repositories.get_visible_by_path!/3 (repositories.ex:72) and the :optional_forge_api pipeline that GET /api/v3/repos/:owner/:repo uses (router.ex:254).
No pagination, one filter. IssueController.index passes only state into Issues.list_issues/2 (issue_controller.ex:10), which returns every match with no limit (issues.ex:26). The paginated, filterable query the web interface uses is right beside it and unreachable from the API: Issues.list_issues_page/2 supports :state, :label, :assignee, :milestone, :q, and :page (issues.ex:40). A repository with two thousand issues returns two thousand issues.
Projects are one repository's projects. Both project read functions scope to Repositories.initial_repository!() (lib/openagents/projects.ex:26, :131), which resolves the module constants OpenAgentsInc and openagents.com (lib/openagents/repositories.ex:22). ProjectController.create checks that the :owner segment matches the caller's GitHub login and then discards it, resolving Repositories.initial_path() for the repository the project attaches to (project_controller.ex:17, :21). A cross-repository paginated query exists in the context for the web interface (projects.ex:43) and the API does not use it. Building openagents project commands on the surface as it stands would ship a command that silently only ever addresses one repository.
4. How the coverage should be produced
This is the part worth being slow about, because the answer determines the cost of every future endpoint.
4.1 What exists today, and what it does not check
No OpenAPI document exists. There is no open_api_spex, phoenix_swagger, or equivalent in mix.exs. The single mention of OpenAPI anywhere in the repository is a future intention in a planning document: docs/repository-creation-and-openagents-cli-spec.md:1008 proposes publishing "a bounded OpenAPI document or equivalent JSON Schema artifact for the repository endpoints" as a step in client-contract synchronization. There is no .well-known route and no capability manifest.
A hand-written contract artifact exists, and it is worth understanding precisely because it is the seed of any answer here. priv/api-contracts/repositories-v1.json is 99 lines, served by ApiContractController.repositories_v1 which reads the file at request time and sets an ETag from its SHA-256 (lib/openagents_web/controllers/api_contract_controller.ex:8). It carries:
- an
endpointsmap of elevenname → "METHOD /path"strings (:18), covering repositories, imports, the authenticated user, and device authorization, and nothing from the issues or projects families; - authentication metadata — bearer,
oa_pat_prefix,forge:writescope (:4); - which four
POSTs requireIdempotency-Key(:8); - flat lists of required field names per object, plus enum values (
:33–:66); - pagination parameter names (
:68) and the error shape with twenty stable codes (:74).
What it does not carry: any type, any request body, any response body, any status code, any relationship between an endpoint and a schema. It is a vocabulary agreement, not a description. You could not generate a client from it. It also is not generated from anything — it is a checked-in file that a human edits.
An executable route inventory exists, and this is the genuinely promising asset. lib/openagents_web/route_authority.ex walks OpenAgentsWeb.Router.__routes__/0 and classifies every route with a class, principal, scope, and mutation flag (:59), and test/openagents_web/route_authority_test.exs:6 fails if any route lacks them. The derivation this repository would need already runs in CI.
But it also demonstrates the failure mode that any derived-document plan has to design against, twice over:
- Its
@moduledocstates "The classifier intentionally has no catch-all policy" (route_authority.ex:5), and for/api/v3there are in fact two catch-alls: any unmatchedGETbecomes:public_readand anything else becomes:authenticated_apiwith scopeforge:write(:195,:198). A new/api/v3route is classified automatically, so adding an endpoint cannot fail this test. - It declares scope
forge:readforGET /api/v3/user,GET /api/v3/user/repos, andGET /api/v3/repository-imports/:id(:184). Those three routes are in the:forge_write_apipipeline (router.ex:260,:262,:263,:269), which requiresforge:write, andforge:readis not a scope that exists anywhere in the system (lib/openagents/api_tokens.ex:12). The label is derived from a hand-written policy function that nothing checks against the plug it describes.
So the state of the art here is: one derivation that runs but does not constrain, and one description that constrains nothing and describes 22% of the surface.
4.2 The four options, honestly
Option A — keep hand-writing commands (status quo).
Cost. Measured from the existing code: repository-client.ts is 595 lines for nine operations (about 66 lines each), and cli.ts is 803 lines for twelve leaf commands (about 67 lines each), in the openagents monorepo. Thirty-nine endpoints at that rate is roughly 2,600 lines of client plus perhaps 1,300 of command surface before tests — which would roughly double a 3,452-line codebase. In a second repository, in a second language, on a second release cadence, published to npm.
What it buys. The best possible user experience at every command, because a human decides what issue list prints, that -R owner/repo falls back to the origin remote, that 41 means issue 41, and that issue create opens $EDITOR.
What it forecloses. Nothing. Hand-written commands compose with every other option.
Where it breaks down. At exactly the volume in front of us. The marginal endpoint is never worth the marginal pull request, so coverage stalls where it is now — which is the observed outcome, since the API has had these 39 routes long enough to accumulate full controller test files for all of them.
Drift. Silent, and this is not hypothetical: it is today's behavior. Nothing fails when an endpoint changes. Both contract tests stay green. The user finds out.
Option B — generate a typed client, hand-write the command surface.
Generate the transport and schema layer from a machine-readable description of this API; keep cli.ts hand-written on top of it.
Cost. Dominated by the prerequisite. The description does not exist and must be produced, and — the important part — it must be derived from the router rather than maintained beside it, or you have moved the drift one layer up and added a file to forget. Paths, methods, and pipeline-derived auth are mechanically derivable from Router.__routes__/0, as RouteAuthority proves. Response schemas are the hard half: they live in the *_json.ex view functions, and six controllers render inline with json/2 and have no view module at all (assignee_controller.ex, issue_assignee_controller.ex, issue_label_controller.ex, forge_user_controller.ex, device_authorization_controller.ex, repository_controller.ex). Deriving the response shape for those means restructuring them first.
What it buys. The 66-lines-per-operation client layer mostly disappears, and — the real prize — a server-side schema change becomes a compile error in the CLI on regeneration, before anyone ships.
What it forecloses. Little. A generated method can always be wrapped or bypassed by hand.
Where it breaks down. When the description lies. A generated client inherits every inaccuracy of its source with total confidence, and the two demonstrated inaccuracies in this repository — the stale repo delete line in the command reference and the forge:read label in the route inventory — are both in hand-maintained descriptions.
Drift. Loud, conditionally. Regeneration diffs, and CI fails on an uncommitted diff — but only for the parts the description actually covers, and only if the description is derived. A hand-maintained OpenAPI file gives you a feeling of drift detection and no drift detection.
Option C — generate the commands too.
Cost. High fixed cost (a generator, plus naming and flag conventions), near-zero marginal cost.
What it buys. All 50 endpoints covered in one step, and every future endpoint free.
What it forecloses. The product. Generated command surfaces are worst precisely where usage is highest. A generator produces openagents repos-owner-repo-issues-list --owner X --repo Y --issue-number 41. It will not infer -R owner/repo from the origin remote — which this CLI already does, at cli.ts:695 — it will not decide that a bare 41 means issue 41, it will not open an editor for a body, it will not render a table, it will not ask before a destructive call the way repo delete demands --yes (cli.ts:761), and it will faithfully expose both the PUT and the PATCH spelling of every update route because the router has both. It will also expose POST /api/v3/:owner/projectsV2 under a different noun than every other project command, because that is what the router says.
Where it breaks down. The first day a person uses it for real work.
Drift. Loud — the regeneration diff — but with a cost the other options do not have: the CLI's public interface becomes generator output, so a path rename on the server renames a user's command.
Option D — a generic passthrough, the way gh api works.
openagents api /repos/OpenAgentsInc/openagents.com/issues
openagents api -X POST -f title="…" -f body="…" /repos/o/r/issues
Cost. One command. The CLI already owns every part except argument parsing: endpoint resolution and profiles (src/endpoint.ts), the credential store and OPENAGENTS_TOKEN fallback (src/session.ts:30), a transport that takes an arbitrary method, path, and JSON body (src/api-transport.ts:82), JSON and human output modes (src/output.ts), and an error decoder with exit-code mapping (src/errors.ts). Estimate 150–250 lines including tests — the size of one existing repo subcommand.
What it buys. Complete endpoint coverage on the day it lands, permanently, at zero marginal cost per endpoint — including endpoints that do not exist yet. It also decouples the two release trains: a new server endpoint becomes reachable from the terminal without an npm publish.
What it forecloses. Nothing. gh ships gh issue and gh api side by side and has for years; the passthrough is where power users and scripts go, and the named commands are where everyone else stays.
Where it breaks down. It is not issue management, it is authenticated curl with sane defaults. The caller has to know the path, spell the JSON, and read the envelope. Nobody triages with it.
Drift. None to detect, because it makes no claims — its correctness does not depend on any description of the API. That is its strength and, symmetrically, its limit: it will also never warn you that the server broke a shape.
4.3 Recommendation
Ship D now, do A for the top ten commands, invest in B only once the description is derived and verified, and never do C.
The reasoning, in order of confidence:
-
Never C. The CLI's entire reason to exist is that it is nicer than
curl. Generating its command surface trades away the only thing it adds, to save work on commands nobody types. Theghprecedent is decisive here — GitHub has the largest OpenAPI description in public software and still hand-writesgh issue. -
D first, because it is the cheapest thing that answers the owner's actual question. "So I can do issues and projects management from the CLI" is satisfied for scripting and one-off work by roughly 200 lines of code, in one repository, with no server change and no coordination. Everything after it is an ergonomics upgrade on a capability that already exists rather than a capability that does not.
-
A for the top ten. Thirty-nine hand-written commands is the trap; ten is a week. The ranking in section 3.1 is the list, and it is short because issue work is concentrated: list, view, create, close, comment, label, assign. Those get hand-written UX because that is where UX pays.
-
B is right, but its prerequisite is the whole job, and the prerequisite is worth doing on its own merits. The rule that makes it worth anything: generate the document from the router, not beside it.
RouteAuthorityalready walksRouter.__routes__/0; extending that walk to emit paths, methods, pipeline-derived auth, and — where a*_json.exmodule exists — the response key set, produces a document that cannot drift on the parts it covers, because CI fails on an uncommitted regeneration diff. Then generate the CLI's schema layer from it.
There is a cheaper move available immediately that is worth naming separately, because it converts today's silence into a red build for about twenty lines per repository, and it is a prerequisite for trusting any of the above:
- In this repository: assert that every route in
Router.__routes__/0whose path starts with/api/v3appears inpriv/api-contracts/repositories-v1.json. Today that test fails immediately on 39 routes, which is the correct first result — it converts "the contract covers 22% of the API" from a fact nobody knows into a fact the build states. - In the
openagentsmonorepo: make the CLI's contract check fetch/api/contracts/repositories-v1.jsonfrom the configured origin and compare it to the vendored copy, rather than hashing the vendored copy against a constant in the same package (packages/openagents-cli/test/api-contract.test.ts:13). Run it against staging in the CLI'sverifyscript. Today it would pass, because the two files happen to be identical; tomorrow it is the only thing that would notice they are not.
The order matters. The passthrough removes the urgency, the drift tests remove the silence, the hand-written commands do the ergonomics, and the derived document — which is real work with a real restructuring prerequisite — happens once the surface has stopped moving, not while it is moving fastest.
5. A staged path to parity
Each stage is independently shippable and independently useful. Sizes are rough and relative.
Stage 1 — Ship openagents api (CLI repository only, small) — SHIPPED 2026-08-21
Add one leaf command taking a path, an optional -X/--method, repeated -f key=value body fields or --input - for raw JSON, and honoring the existing --json and profile flags. Seam: packages/openagents-cli/src/cli.ts plus a thin passthrough client in the openagents monorepo; no schema, no server change. Size: 150–250 lines with tests. Effect: every one of the 50 endpoints becomes reachable from a terminal, and every future endpoint arrives free. The CLI documentation reconciliation on 2026-08-22 removed the stale claims that generic API access and repo delete were unavailable and added tested Issues and Projects recipes.
What shipped. eaa2aa1006 in the openagents monorepo, released as
@openagentsinc/cli@0.2.1. The command is openagents api <path>: a path
without a leading slash resolves under /api/v3/, an absolute path must start
with /api/ and stay on the selected origin, -X covers GET, POST, PATCH,
PUT, and DELETE, -f key=value repeats into a JSON object, --input <file|->
takes a whole body from a file or standard input, -H repeats headers and
refuses to overwrite authorization, and --profile/--api-url/--json
behave as they do elsewhere. The body goes to stdout as JSON so it pipes into
jq; a non-2xx writes status, body, and request id to stderr and exits
non-zero, keeping stdout clean. 32 tests, and ApiTransport gained PATCH and
PUT, which it had never carried (api-transport.ts:8) — the audit missed that
the transport could not express the issue-update route at all.
What it cost to publish, which is worth recording. 0.2.0 went to npm with
"effect": "catalog:" in its dependencies and installed nowhere. The monorepo
is a pnpm workspace: pnpm resolves catalog references while packing, npm publish does not, and the tarball reaches the registry looking healthy. npm
refuses unpublishing outside its window, so 0.2.0 could only be deprecated
and replaced. The rule now lives in that repository's AGENTS.md and
docs/DEPLOYMENT.md, along with the check that catches it in seconds:
install the published version from the registry rather than reading the
publish output.
What Stage 1 does not fix, both from section 3.2 and both server-side:
reading an issue back from a private repository still answers 404, because the
read pipeline discards the bearer token, so the passthrough can create an issue
it cannot then show you. And the projectsV2 routes still resolve through
Repositories.initial_repository!/0, so openagents api users/me/projectsV2
returns real-looking JSON describing one hardcoded repository rather than
yours. A passthrough is faithful by design: it exposes those surfaces exactly
as they are, including where they are wrong.
Stage 2 — Let an authenticated caller read their own private issues (this repository only, medium) — SHIPPED 2026-08-23
Issue list and detail reads accepted an optional bearer from 2026-08-22. The
remaining ten routes — comments, one comment, issue labels, issue assignees,
labels, one label, milestones, one milestone, assignees, and one assignee —
moved on 2026-08-23. Each resolves the repository through
Repositories.get_visible_by_path!/3 and then reads through the
repository-scoped getter, which is how the issue reads were already written, so
there is one resolution pattern rather than two. The three public-only path
resolvers that had no other caller — Issues.get_comment_by_path!/3,
Labels.get_label_by_path!/3, and Milestones.get_milestone_by_path!/3 — were
removed rather than left beside the predicate that replaced them.
OpenAgents.DataRights.ExportInventory reclassified the six families from
:blocked to :portable in the same change, which is what EXIT-001 exists to
force.
The original plan follows. Move the issue, comment, label, milestone, and assignee read routes (router.ex:235–:246) from pipe_through :api into pipe_through :optional_forge_api, and change the resolution calls in IssueController, CommentController, IssueLabelController, IssueAssigneeController, LabelController, MilestoneController, and AssigneeController from Repositories.get_public_by_path!/2 to Repositories.get_visible_by_path!/3, threading conn.assigns.current_user (which the optional plug sets to nil for anonymous callers). The visibility filters inside Issues.get_issue_by_path!/3 (issues.ex:159), Issues.get_comment_by_path!/3, Labels.get_label_by_path!/3, and Milestones.get_milestone_by_path!/3 need the same treatment. Seam: router pipelines, ten or so controller call sites, four context queries, and the RouteAuthority policy at route_authority.ex:184, whose :public_read classification for these paths becomes wrong. Size: medium; mechanical but wide, and every one of these controllers already has a test file to extend. Effect: additive and backward-compatible — anonymous callers see exactly what they see today; the change is that a bearer token now widens the result instead of being discarded. Without this, sections 3.1 ranks 1, 2, 8, and 9 only ever work on public repositories, so this gates most of the value of Stage 3.
Stage 3 — The five commands that carry the traffic (CLI repository only, medium)
issue list, issue view, issue create, issue close / reopen, issue comment. Hand-written, with -R owner/repo falling back to the origin remote the way repo view already does (cli.ts:695), bare integers for issue numbers, --json for scripting, and a table for humans. Seam: new issue-client.ts and schema additions in api-contract.ts in the openagents monorepo, on top of Stage 2's reads. Size: medium — roughly five client methods and five commands at the codebase's observed 66 lines each, plus tests.
Stage 4 — Pagination and filters on the API list endpoints (this repository only, small)
Change IssueController.index to call Issues.list_issues_page/2 and pass through label, assignee, milestone, q, and page (issue_controller.ex:10; the query already supports all of them at issues.ex:40). Decide the pagination contract deliberately — the repository list already uses opaque cursors (next_cursor, after, priv/api-contracts/repositories-v1.json:68) while the issue context is offset-paged, and shipping a third convention would be a mistake. Size: small on the server; unlocks issue list -l bug -a me in the CLI without further server work.
Stage 5 — Derive the contract document, and fail CI on divergence (both repositories, large)
Extend the RouteAuthority walk to emit the full /api/v3 surface — path, method, pipeline-derived auth and scope, and response key sets where a *_json.ex view exists — into the published contract artifact, and add the two drift tests from section 4.3. Restructure the six controllers that render inline with json/2 into view modules first, or accept that their responses stay undescribed. Size: large, and it is the only stage that should wait.
Stage 6 — Labels, milestones, and assignee commands (CLI repository, medium)
Ranks 6 through 9. Straightforward once Stage 3 has established the client and command shapes.
Stage 7 — Make projects addressable, then build project commands (this repository, then CLI; large)
Shipped on 2026-08-22. Projects now use repository-shaped routes and no
production context selects a default repository. The generic openagents api
command can address the resulting project surface; dedicated project commands
remain future CLI work.
Unpin the project surface from Repositories.initial_repository!() (projects.ex:26, :131), make ProjectController.create honor its own :owner segment instead of resolving Repositories.initial_path() (project_controller.ex:21), reconcile POST /api/v3/:owner/projectsV2 with the /users/:username/projectsV2 shape every other project route uses (router.ex:290), and give the project reads a visibility predicate. Only then are there project commands worth writing. Size: large, and it is genuinely a redesign rather than a port.
What makes issue management usable this week versus complete
Stage 1 makes every implemented route reachable. Stage 2 makes the issue workflow — issues, comments, labels, milestones, and assignees — usable for private repositories, and Stage 3 makes the common workflow concise. Stages 4 through 7 complete more of the contract. Stage 5 keeps the server and client descriptions aligned.
The cross-repository cost, and how to handle it
The CLI ships from the openagents monorepo as @openagentsinc/cli, currently
published to npm at version 0.2.1, with its own verify pipeline (format,
lint, typecheck, test, and build) and its own release cadence. This repository
deploys on its own. Four rules keep that from becoming a coordination tax:
- Order every stage server-first. A released CLI in a user's
$PATHwill meet a deployed server. The reverse — a server change that only works with an unreleased CLI — creates a window where the published tool is broken. Stages 2, 4, and 7 land and deploy before the CLI stage that depends on them. - Never require simultaneous merges. Each stage above touches exactly one repository, by construction. Stage 2 is additive: anonymous reads keep their current behavior, so no released CLI breaks.
- Use Stage 1 to buy slack. The passthrough's real strategic value is that it decouples the release trains — a new endpoint is reachable from the terminal the moment it deploys, with no npm publish, so nothing is ever blocked on a CLI release again.
- Make the one shared artifact a build failure rather than a memory.
priv/api-contracts/repositories-v1.jsonand its vendored twin atpackages/openagents-cli/contracts/repositories-v1.jsonare the only file that must change in both repositories together. Generate it here, vendor it there, and have the CLI's CI fetch and compare (section 4.3). That converts a coordination problem into a named, failing test in a specific repository — which is the only form of cross-repository discipline that survives contact with two release cadences.
6. What is worth preserving
Not everything here needs changing, and two decisions are better than they look:
- The bearer-token boundary is clean. One credential family, one plug, one scope check, constant-time comparison, revocation and expiry honored,
last_used_atbumped (lib/openagents/api_tokens.ex:52). The CLI's device flow and credential store are built to match it, and the--profileand--api-urlsplit means a single binary talks to production, staging, and a local server without reconfiguration (packages/openagents-cli/src/endpoint.ts:8in theopenagentsmonorepo). RouteAuthorityis the right instinct in the wrong strength. An executable inventory that walks the real router and fails a test is exactly the mechanism a derived contract needs. It needs its two catch-alls narrowed and its scope labels checked against the pipelines that enforce them, not replacing.- The CLI's decode-on-every-response discipline is worth keeping under any generation strategy. A
ContractErrornaming the operation (packages/openagents-cli/src/repository-client.ts:227) is a much better failure than silently readingundefinedoff a changed field.
7. Open questions
- Does the published npm package's vendored contract match the deployed server's? Both measured working copies hash to
5be86539258c…, but the CLI still verifies only its vendored copy. Compare the packed@openagentsinc/cli@0.2.1artifact with the deployed/api/contracts/repositories-v1.json, then make the comparison a staging verification failure. Which ancillary reads should accept an optional bearer?Settled on 2026-08-23: every issue-family read does. Repository, issue, project, comment, label, milestone, assignee, issue-label, and issue-assignee reads all authorize private repository members through the same predicate.- Is
forge:readinroute_authority.ex:184an aspiration or a mistake? If the intent is a read-only token scope, it needs adding to@allowed_scopes(lib/openagents/api_tokens.ex:12) and a:forge_read_apipipeline; if not, the label should sayforge:write. Settle by asking whether a token that can file an issue should also be able to delete a repository, which is today's answer. - Does anything consume the
{"issues": [...]}envelope, or could the list endpoints move to bare arrays for GitHub compatibility? Settle by grepping theopenagentsmonorepo and this repository's web layer for consumers before Stage 5 freezes the shape into a generated document. - What is the intended pagination convention across the whole API? Repositories use opaque cursors and issues are offset-paged internally. Stage 4 has to pick one, and the choice belongs to whoever owns the contract document, not to the first endpoint that needs a page.