Paste https://packageurls.com/agents.md to a coding agent. The same text is below.openapi.json is the API contract.llms.txt lists both.
Onboard a repository to Package URLs
Follow this document in order. The canonical URL is https://packageurls.com/agents.md. The request and response shapes are https://packageurls.com/openapi.json.
Package URLs publishes one hostname the customer owns. A Go hostname answers ?go-get=1 with a go-import meta tag. A PHP hostname, a Rust hostname, a Swift hostname, a Ruby hostname, and an Elixir hostname answer every path with 307 to the same path on the forge. The edge never proxies git. The code stays on the forge.
You will create one route per language, show the human the DNS records, wait until the certificate is active, and open one pull request per repository. Almost every organization is one language, so that is one route and two DNS records. The human does two things: sign in, and create those DNS records. You do not create DNS records, buy a hostname, or change the plan.
The control plane is https://api.packageurls.com. Customer hostnames are not the API. Send Authorization: Bearer and Content-Type: application/json on requests that have a body. Errors are JSON {"message":"..."}.
Facts you cannot derive
- Only
go,php,rust,swift,ruby, andelixirwork.POST /routeswith any other language returns400andlanguage must be go, php, rust, swift, ruby, or elixir. The namesdart,haskell,clojure, andscalaare reserved CNAME aliases, not languages. Do not send them. - One hostname covers every
owner/repounder it. Do not create a route per repository. - The path’s first two segments are the repository:
<hostname>/<owner>/<repo>maps to<git_url>/<owner>/<repo>. A third segment is not another repository. A Go/v2(or later) suffix stays on the module path and is not a repository segment. - Nested GitLab groups do not map.
group/subgroup/projectis three segments. Stop and ask the human before creating a route if any repository you must publish lives at a forge path deeper thanowner/repo. - Paths with fewer than two segments, a segment that is not
[A-Za-z0-9\-_.~+]or that starts or ends with., an unknown host, and an unclaimed host return404with an empty body andCache-Control: no-store. - The edge serves a route only after the hostname is verified and
ssl_statusisactive. Verification without an active certificate is normal. It is not a failure, and the hostname is not live yet. PATCH /routes/{id}changes the forge root (git_url) only. Import paths stay the same. Language and hostname are fixed at creation.- Free is 1 route and 10,000 requests a month. Past that, the lease issuer stops resolution. The edge returns
402and the bodyFree includes 10,000 requests a month. Upgrade to Pro to keep this module resolving. - Pro is 25 routes and 100,000 requests a month. Max is 100 routes and 1,000,000 requests a month. On Pro and Max, requests past the allowance still resolve. Overage is never billed.
- There is no API-key endpoint. There is no
POST /account/api-keys. Do not call Clerk device-authorization or token endpoints against this API. This API accepts a Clerk session JWT.
1. Credentials
This is the first human action.
-
Ask the human to open
https://packageurls.com/signinand finish sign-in. If they have no account, they usehttps://packageurls.com/signup. Email-link sign-in is enough. A password is optional. -
Read the session JWT from that signed-in browser on
https://packageurls.com:await window.Clerk.session.getToken()If
window.Clerkis missing, openhttps://packageurls.com/signinso the sign-in widget loads Clerk, then run the same call. If you cannot run JavaScript in that browser, ask the human to paste the result of that call. The paste is part of this same sign-in, not a second action. -
Call
GET /mewithAuthorization: Bearer <token>.200anduser.can_create_routes: truemeans you may create a route.200anduser.can_create_routes: falsemeans stop. Tell the human to verify their email athttps://packageurls.com/profile. Creating a route would return403andVerify your email before creating routes.Do not create a route.401and{"message":"not signed in"}means the token is missing, expired, or not a session JWT. Read a fresh token and retry this call. Do not create a route.
-
Session tokens expire quickly. On any later
401with that message, read a fresh token and retry the same request once. Do not start over, and do not create a second route.
GET /billing/status tells you the stored plan. A live paid plan has status of active, trialing, or past_due and plan of pro_month, pro_year, max_month, or max_year. Any other status is Free, including none and canceled. If this call fails, continue with GET /me and GET /routes. Do not call checkout, and do not change the plan yourself. The human changes plans at https://packageurls.com/pricing.
2. Inventory and the plan
Inventory only the repositories you were given.
Count repositories whose Go module path, Composer vcs URL, Cargo git dependency, Swift package URL, Bundler git source, or Mix git dependency uses the old forge host you are replacing, such as github.com/acme/widgets. That count is how many pull requests you will open. It is not how many routes you will create.
Choose one hostname with the human. It must be a domain they control, with no scheme and no path, such as go.example.com or php.example.com. A scratch run may use a name they control on packageurls.dev. You still do not create the DNS records.
Choose the language from the repositories:
- Go modules only: one route,
languagego, CNAME targetgo.packageurls.com. - PHP packages only: one route,
languagephp, CNAME targetphp.packageurls.com. - Rust crates only: one route,
languagerust, CNAME targetrust.packageurls.com. - Swift packages only: one route,
languageswift, CNAME targetswift.packageurls.com. - Ruby gems only: one route,
languageruby, CNAME targetruby.packageurls.com. - Elixir projects only: one route,
languageelixir, CNAME targetelixir.packageurls.com. Hex registry dependencies ({:widgets, "~> 1.0"}) stay on hex.pm. This route is the git dependency only. - More than one language: one hostname and one route per language. Free allows 1 route. Tell the human, and recommend Pro before the second route. If they stay on Free, create only the language they choose and stop after that route. Do not create another route hoping the cap will make an exception.
Recommend a plan from expected go get meta lookups, Composer fetches, Cargo git fetches, Swift package resolves, Bundler git fetches, and Mix git fetches, not from the repository count. Default to Free unless that volume clearly exceeds 10,000 requests a month. Use GET /account/usage when this account already serves traffic (request_count for the current period). A new account is at zero, so estimate from how often CI and downstream builds fetch these modules. Recommend Pro only when the estimate is clearly above 10,000 and at or under 100,000. Recommend Max only when it is clearly above 100,000. Say the recommendation and the reason. Then continue on the plan they already have. Free is the right create when the estimate is not clearly over 10,000 and they have a free route slot.
Before POST /routes, stop and ask the human when either of these is true:
- A repository you must publish is not exactly two forge segments (
owner/repo). Nested GitLab groups (group/subgroup/project) do not map. Do not work around that with a route per repository, and do not put a path ongit_url. - You cannot tell which hostname they want.
3. Create the route
Call GET /routes first.
- If a route already exists for this hostname and language, use its
id. Do not create another. - If one exists for this hostname with the other language, stop. Language cannot be changed. Tell the human.
- If the list is already at the plan’s route cap (1, 25, or 100) and none of the routes is the hostname you need, stop. The create would return
409andThis account is already at the <plan> plan's route cap of <n>.Point them athttps://packageurls.com/pricing. Do not delete a route unless they say that route is abandoned.
Create when there is no matching route and the account is under its cap:
POST /routes
Authorization: Bearer <token>
Content-Type: application/json
{"hostname":"go.example.com","git_url":"https://github.com","language":"go"}
hostname is a domain name. The API lowercases it and strips one trailing dot. git_url is a bare origin: https://github.com, https://gitlab.com, https://codeberg.org, or another http or https origin. No userinfo, path, query, or fragment. language is go, php, rust, swift, ruby, or elixir. Always send it.
A 200 body includes id, hostname, git_url, language, cname_target, verified (false), verify_token, created_at, and updated_at. ssl_status is absent or empty until certificate provisioning starts. Save id, cname_target, and verify_token from this body. Do not invent the CNAME target. Go is go.packageurls.com. PHP is php.packageurls.com. Rust is rust.packageurls.com. Swift is swift.packageurls.com. Ruby is ruby.packageurls.com. Elixir is elixir.packageurls.com.
Other create results:
400language must be go, php, rust, swift, ruby, or elixir. You sent the wrong language. Fix the body. Do not retry with a reserved alias.400with a hostname or forge message. Show the human the message and fix the body. A forge URL with a path is rejected.403Verify your email before creating routes.Stop at step 1.409That hostname is already claimed.Stop. Ask the human for a different hostname. Do not guess.409route cap, as above. Stop.401. Refresh the token and retry this create once. If the first call returned200, do not create again. UseGET /routes.
The new route is unverified. Nothing resolves on that hostname yet.
4. DNS records
This is the second human action. You cannot edit their DNS.
Print these two records, using the cname_target and verify_token from the route. The TXT value is the token alone, with no name, quotes, or prefix. The whole TXT string must equal the token.
go.example.com CNAME go.packageurls.com
_packageurls.go.example.com TXT <verify_token>
The CNAME name is the route hostname. The TXT name is _packageurls. plus that hostname. Ask the human to create both records and reply when they have. Leave the route in place while they do it.
5. Poll verification
After you have printed the records, poll until you know which branch you are in. Wait 15 seconds, then 30, then 60, then 120, and stay at 120 seconds.
POST /routes/{id}/verify
Authorization: Bearer <token>
The body may be empty. Read outcome, retryable, and route.
verified. DNS ownership passed.route.verifiedistrue. Go to step 6. Do not rewrite imports yet.token-missing. A TXT record exists at_packageurls.<hostname>and it is not the token. Stop the timer. Show the human the same two records again and say the TXT value does not match. Do not tell them the CNAME is the problem. Do not callPOST /routes/{id}/verify-token/rotateunless they say the token leaked. Rotation changes the token and clears verification. When they say the TXT is fixed, resume polling.dns-error. The lookup failed.retryableistrue. Keep polling. Do not tell the human the record is wrong.nxdomain-or-no-txt. No TXT yet. This is the expected state until the human publishes the record. Keep polling.retryableisfalsehere. That flag does not mean stop. It means the lookup did not find a TXT record.401. Refresh the token and retry this verify.404route not found. Stop. The route is gone or it is not this account’s. Do not create a replacement unless the human asks.5xx. Treat it likedns-errorand keep polling.
verified is the only outcome that continues the run.
6. Wait for the certificate
On verified, the API starts certificate provisioning. The verify body already includes route.ssl_status. If that value is active, go to step 7.
Otherwise poll GET /routes/{id} on the same backoff. While you wait you may call POST /routes/{id}/ssl to refresh a certificate that is still converging. Do not call POST /internal/ssl-sync. That cron is not yours. It converges pending certificates without you.
Keep polling while ssl_status is missing, empty, initializing, starts with pending, or starts with hold. Pending includes pending_validation, pending_issuance, and pending_deployment. An empty or pending status after verification is normal. Do not fail the run, do not delete the route, and do not open the pull request.
Stop the wait, leave the route, and follow the section “If you stop” when ssl_status is failed, not-configured, quota-exceeded, or contains timed_out, or when it is some other value that is not active and not in the converging list above. The hostname is not serving.
active is the only status that means you may rewrite imports.
7. Rewrite and open the pull request
Confirm the edge before you edit. These checks talk to the customer hostname, not the API.
Go, after replacing the example names:
curl -fsS -D - "https://go.example.com/acme/widgets?go-get=1"
Expect 200, Content-Type HTML, Cache-Control: no-store, and a meta tag:
<meta name="go-import" content="go.example.com/acme/widgets git https://github.com/acme/widgets">
The prefix is <hostname>/<owner>/<repo>. The repo URL is <git_url>/<owner>/<repo>. A browser request without ?go-get=1 is a 302 to that repo URL.
PHP:
curl -fsS -o /dev/null -D - "https://php.example.com/acme/widgets"
Expect 307, an empty body, Cache-Control: no-store, and a Location that ends in /acme/widgets on the forge. The PHP host forwards the full path, so Composer and git can follow /info/refs and /git-upload-pack onto <git_url>/<owner>/<repo>/.... GET /acme/widgets?go-get=1 on a PHP host is still that 307, not an HTML meta page. POST /acme/widgets on a Go host is 405 with Allow: GET, HEAD.
Rust uses the same redirect. After replacing the example names:
curl -fsS -o /dev/null -D - "https://rust.example.com/acme/widgets/info/refs?service=git-upload-pack"
Expect 307, an empty body, Cache-Control: no-store, and Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack. The query string stays. Cargo’s git fetch follows that redirect and then talks to the forge.
Swift uses the same redirect. After replacing the example names:
curl -fsS -o /dev/null -D - "https://swift.example.com/acme/widgets/info/refs?service=git-upload-pack"
Expect 307, an empty body, Cache-Control: no-store, and Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack. The query string stays. Swift Package Manager’s git clone follows that redirect and then talks to the forge.
Ruby uses the same redirect. After replacing the example names:
curl -fsS -o /dev/null -D - "https://ruby.example.com/acme/widgets/info/refs?service=git-upload-pack"
Expect 307, an empty body, Cache-Control: no-store, and Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack. The query string stays. Bundler’s git clone follows that redirect and then talks to the forge.
Elixir uses the same redirect. After replacing the example names:
curl -fsS -o /dev/null -D - "https://elixir.example.com/acme/widgets/info/refs?service=git-upload-pack"
Expect 307, an empty body, Cache-Control: no-store, and Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack. The query string stays. Mix’s git clone follows that redirect and then talks to the forge.
If the check is 404, the certificate is not serving yet. Return to step 6. If it is 402, Free has used its 10,000 requests this month. Stop the rewrite, leave the route, and tell the human to move to Pro. If it is 403 and the body is This module is paused., stop and tell the human. Do not edit imports.
When the check matches, open one pull request per repository you inventoried. Do not change the repository’s git remote. Consumers use the vanity host. Git still clones the forge, because the edge does not proxy git.
Go, in that repository:
- The
moduleline ingo.mod, when it is the module you are moving. This is required, not cosmetic:gorefuses a fetched module whose declared path does not match the requested one and reportsmodule declares its path as ... but was required as .... The vanity path becomes the module’s only canonical path; the old forge path no longer resolves forgo get. - Import strings in
.gofiles, andrequirelines for that module. - README and docs that tell someone to
go getthe old module path. - CI scripts that
go getor install that module path. - Keep a
/v2or later suffix.github.com/acme/widgets/v2becomesgo.example.com/acme/widgets/v2. - A module nested below
<owner>/<repo>keeps its remaining path segments, for example<hostname>/<owner>/<repo>/apps/backend. - Leave
github.comURLs that are not this module: issues, actions, other organizations, and the sentence that says where the source lives. - Set
GOPRIVATEto the vanity hostname. It coversGONOSUMDBandGONOPROXY, so the checksum database and the public proxy never see the module path.go env -w GOPRIVATE=<hostname>keeps the setting for later shells and CI. Then rungo get <hostname>/<owner>/<repo>(include/vNwhen that is the module). DefaultGOPROXY(https://proxy.golang.org,direct) is right when the module has public dependencies. A private forge still needs the human’s existing git credentials. Package URLs does not issue those. Commit thego.sumupdate.
PHP, in the consuming repository or the one that declares the vcs repository:
- Change the Composer repository URL from the forge to
https://<hostname>/<owner>/<repo>. The usual shape is{ "type": "vcs", "url": "https://php.example.com/acme/widgets" }. - Change README and CI clones of that forge repository to the vanity URL.
- Leave the package
nameincomposer.jsonas the forge publishes it. - Run
composer installwhen Composer is available. Otherwise rungit ls-remote https://<hostname>/<owner>/<repo>. A private forge still needs the human’s existing git credentials. Package URLs does not issue those.
Rust, in the consuming crate:
- Change the git dependency from the forge URL to
https://<hostname>/<owner>/<repo>. The usual shape iswidgets = { git = "https://rust.example.com/acme/widgets" }. - Change README and CI clones of that forge repository to the vanity URL.
- Leave the package
nameinCargo.tomlas the forge publishes it. - Run
cargo buildwhen Cargo is available. Otherwise rungit ls-remote https://<hostname>/<owner>/<repo>. A private forge still needs the human’s existing git credentials. Package URLs does not issue those.
Swift, in the consuming package:
- Change the package URL from the forge to
https://<hostname>/<owner>/<repo>. The usual shape is.package(url: "https://swift.example.com/acme/widgets", from: "1.0.0"). - Change README and CI clones of that forge repository to the vanity URL.
- Leave the package
nameinPackage.swiftas the forge publishes it. - Run
swift package resolvewhen Swift is available. Otherwise rungit ls-remote https://<hostname>/<owner>/<repo>. A private forge still needs the human’s existing git credentials. Package URLs does not issue those.
Ruby, in the consuming Gemfile:
- Change the git source from the forge URL to
https://<hostname>/<owner>/<repo>. The usual shape isgem "widgets", git: "https://ruby.example.com/acme/widgets". - Change README and CI clones of that forge repository to the vanity URL.
- Leave the gem name in the gemspec as the forge publishes it.
- Run
bundle installwhen Bundler is available. Otherwise rungit ls-remote https://<hostname>/<owner>/<repo>. A private forge still needs the human’s existing git credentials. Package URLs does not issue those.
Elixir, in the consuming mix.exs:
- Change the git dependency from the forge URL to
https://<hostname>/<owner>/<repo>. The usual shape is{:widgets, git: "https://elixir.example.com/acme/widgets"}. - Change README and CI clones of that forge repository to the vanity URL.
- Leave the app name in
mix.exsas the forge publishes it. - Run
mix deps.getwhen Mix is available. Otherwise rungit ls-remote https://<hostname>/<owner>/<repo>. A private forge still needs the human’s existing git credentials. Package URLs does not issue those. - A Hex dependency (
{:widgets, "~> 1.0"}) stays on hex.pm. This route covers the git dependency only.
Do this on a branch. The pull request should say which hostname and route id it uses, and the output of the go get, Composer, cargo build, swift package resolve, bundle install, mix deps.get, or git ls-remote check. If that check fails after the curl check passed, fix the branch. Do not delete the route.
If you stop
A partial run is an unverified route, or a verified route whose certificate is not active. Neither publishes a live hostname. If the certificate is already active and you stop before the pull request, the hostname is live and the imports are unchanged. Do not delete the route unless the human asks. Do not rotate the verify token. Do not open the import pull request.
Tell the human:
id,hostname,language,verified, andssl_status- the CNAME and TXT records, when
verifiedis still false - the next call:
POST /routes/{id}/verify, orGET /routes/{id}when it is already verified - that imports were not changed and the hostname is not serving
They can resume by giving an agent this guide, the route id, and a fresh session token.