API overview
A token belongs to a person, and every call runs as that person.
Every tool internkim uses inside a company is callable from outside it, over
HTTP, at https://api.intern.kim/v1. Every company we host calls that one
address; the token is what says which company, and which person inside it. A
company hosting internkim itself calls its own host at /api/v1, which is the
same API on the same code. A token belongs to a person and every call runs as
that person, so a token can never do more than its owner can.
The web app answers the request. It resolves the caller, then answers what it knows: the tool catalog, the tokens a person holds, a file kept for a later call, and every tool that reads or writes the record itself. What is left needs the company's computer — the messenger, the workspace, the browser — so those calls are carried there and the answer comes back.
Invoking tools
List the tools, call one, read what actually changed
Driving the agent
Send a message and let the agent choose the tools
Getting a token
The first token is made signed in, from the personal access tokens panel in settings. It is shown once.
After that a token makes its own successors, so a script rotates one without a browser:
curl -X POST https://api.intern.kim/v1/token \
--header "Authorization: Bearer $INTERNKIM_TOKEN" \
--header 'Content-Type: application/json' \
--data '{"name":"reporting script","permission":"write"}'{ "name": "reporting script", "permission": "write", "expiresAt": "2027-01-01T09:00:00.000Z", "token": "ik_..." }token is answered once and only its SHA-256 hash is kept, so a lost token is
replaced rather than recovered. permission defaults to the rung the calling
token holds, and a token may never mint one that reaches further than itself.
name may be left out, and the token is called pat-1, pat-2, and so on by
the first ordinal the member does not already hold.
A token expires 90 days after it is made, or after expiresInDays, a whole
number from 1 to 365. Calling with an expired token answers 401, and making
another by the same name renews it.
GET /v1/tokens lists the names, rungs, expiry and last use of the tokens the
member holds, and is the only
plural path: POST /v1/token makes one and DELETE /v1/token?name=<name>
revokes one, because each acts on exactly one token.
Rotating a token
A token cannot revoke itself, and that is what makes rotation safe: the replacement has to answer before the token it replaces goes away.
POST /v1/tokenwith the old token, naming the new one.- Call something with the new token and see it answer.
DELETE /v1/token?name=<old>with the new token.
Naming the presented token in either call answers 409.
Authentication
Every other endpoint takes the token as a bearer header:
curl https://api.intern.kim/v1/tools \
--header "Authorization: Bearer $INTERNKIM_TOKEN"The owner is re-resolved on every request. When the owner stops being active
member, every call with their token answers 403 from that moment on; nothing
needs to be cleaned up first.
A signed-in web session works the same way: the bearer may be the session's own Supabase access token. The session is the person, so it carries full permission — the web app calls these endpoints with it, and a script that already holds a session can too. Issued keys exist for everything that should not hold a session: they name their own permission rung and survive sign-out.
Connecting an MCP client
https://api.intern.kim/v1/mcp serves the same catalog as a Streamable HTTP
MCP server, and a client that implements MCP authorization needs nothing but
that address:
claude mcp add --transport http internkim https://api.intern.kim/v1/mcp
claude mcp login internkimA call without a token answers 401 with
WWW-Authenticate: Bearer resource_metadata="https://api.intern.kim/.well-known/oauth-protected-resource/v1/mcp".
That document names the plane's Supabase Auth as the authorization server. The
client registers itself there, opens intern.kim/oauth/consent for the person
to sign in and allow it, and keeps the token it is given. The token acts as
that person, at the rung they chose on the consent screen: read, or read and
write. An app nobody chose a rung for reads, and none administers the company.
The app is listed under connected apps in settings, and disconnecting it
refuses its next call.
Anyone can register a client under any name, so the consent screen also shows
where the access goes. A client on your own computer returns to localhost or
its own app address. When the address is a website instead, the screen warns
that the site receives the access; allow it only if you started connecting and
know that site.
Starting from nothing
The person that window opens for does not need an account yet. The sign-in card offers to claim an invited address or to start a company, and either one carries the pending authorization, so they come back to the consent screen and allow the client there.
POST /api/company, the app's own endpoint that founds a company for the
signed-in account, accepts an OAuth access token, so a client can take the
person that far itself. One account founds one company.
Connecting the company computer is not open to a client.
POST /api/company/host-setup, which answers the connection file, and the
routes that claim a box, invite or remove members, and choose a connected app's
rung refuse both an app's token and an ik_ token: only the person's own
session reaches them. The tools for the same acts are held to the token's rung
instead. person_invite takes a write token. person_withdraw,
person_password_reset and person_message_delete are destructive, so they
take an ik_ token with the delete rung, which no app's token reaches. What the
connection file is for is in the Quickstart,
and the install runs on that computer, so nothing here performs it.
Until the account belongs to a company, every tool call answers 403 naming
that as what is missing, and the same client succeeds once it does.
The internkim plugin
declares the same server in its mcp.json, so installing the plugin
connects it the same way. A client without MCP authorization sends an ik_ token as
the bearer header.
Permissions
A token holds one permission, and there are three of them. Each reaches
everything below it, and what a tool needs comes from its sideEffectClass,
never from its name.
| Permission | Opens |
|---|---|
read | tools whose sideEffectClass is read |
write | those, plus every writing class: workspace writes, external edits and sends, publishing, connections, browser actions, and the agent endpoints |
delete | everything, including deletes |
The writing set is the list in web/src/lib/server/public-api/catalog.ts, and
a descriptor carrying a sideEffectClass that is not in it needs delete — in
the app that lists the tools and again on the machine that runs one. An
unrecognized class fails closed at both ends.
Errors
Success bodies are JSON. Error bodies are plain text, with two exceptions the
app answers as JSON: the catalog's own {"error":"not found"}, and a
hint refusal, which carries
errorCode, failureStage, retryable, safeRetry, hint and candidates
so the next call can name one.
Two machines answer, and they do not share their wording. The app answers everything it resolves itself:
| Status | Body | When |
|---|---|---|
400 | decode error, this call carried a body that is not a json object, input is the object the tool reads | the body is not valid JSON, or input is not an object |
400 | <field>: <what the schema says>, such as input.title: Invalid input: expected string, received undefined | input does not satisfy the tool's published inputSchema |
401 | sign in first | no Authorization: Bearer header, or the token names no account |
401 | that key belongs to nobody | the ik_ token is unknown or revoked |
403 | refused | the account signed in is a member of no company |
403 | token owner is not active member | the ik_ token's owner left or was deactivated |
403 | this token may only <permission>, and <tool> <permission>s | the token's permission is below what the tool requires |
403 | write permission required | an agent endpoint with a read-only token |
404 | {"error":"not found"} | GET /v1/tools/<name> for a tool this token cannot see |
409 | a token cannot revoke itself... | DELETE /v1/token naming the token that authenticated the call |
409 | the hint refusal, with errorCode, hint and candidates | a hint that matched more than one thing, came close to some, or matched nothing |
500 | the control plane is not configured | the app is missing its Supabase settings |
502 | the gateway answered <status>, and no status a caller can use | the company answered with nothing a caller can use |
503 | the company gateway is not configured | an invocation where nothing knows how to reach the company |
A call carried to the company is answered by admind, in its own words:
| Status | Body | When |
|---|---|---|
401 | bearer token required, invalid bearer token | the company machine did not recognize the call |
403 | token owner is not active member | the owner left or was deactivated |
403 | asserted requester is not active member | the named requester is not a member |
403 | tool is not available for this token | an unknown tool, or one above the token's permission |
502 | whatever failed to resolve | the descriptor or the actor could not be read |
Host diagnostics
Company administrators can read the work-history page's existing views through
GET /v1/diagnostics/{view} or the host_diagnostics_get MCP tool. Both reuse
the page's readers and return its recorded evidence. The served reference
lists the views and query fields from the tool contract.
Keeping a file
POST /v1/files takes the bytes themselves, up to 25 MB, with the file's own
content type and an optional ?filename=. The file is kept in the company's
store and materialised into the workspace, and the answer names the path a
later call can reach it by. A reading token is refused here, because keeping
bytes anywhere is a write.
Revoking a token
DELETE /v1/token?name=<name> forgets the token by that name, and the next
call carrying it is refused. A token cannot revoke itself; revoke it with
whichever token replaced it.
The served reference
docs.intern.kim/api serves an interactive reference, in Korean or English,
rendered from an OpenAPI document at docs.intern.kim/openapi/<language>.json.
/api-docs and /openapi/<language>.json on any company host redirect there.
The base tools in that document are read from the same catalog the
agent runs on.
Where it is implemented
The API is part of the web app, so a company that hosts the app hosts the API.
api.<zone>/v1 and <company host>/api/v1 are the same routes.
| Concern | Where |
|---|---|
Routing for everything under /api/v1 | web/src/routes/api/v1/; /v1/* is rewritten onto it in web/src/hooks.ts |
Who is calling, from a session token or an ik_ key | callingMember in web/src/lib/server/member-request.ts |
Token issue, ik_ format, SHA-256 hashing, store | issuePersonalAccessToken in web/src/lib/server/control-plane.ts; the rules in web/src/lib/server/public-api/tokens.ts |
| Permission rungs and the tools each reaches | web/src/lib/server/public-api/catalog.ts, over web/src/lib/public-api-permission.ts |
| Carrying a tool call to the company machine | web/src/lib/server/public-api/company-call.ts, through workers/connection-gateway to the company's relay |
| What the company machine does with it | handlePublicAPI in internal/admind/public_tool_gateway.go, then invokeCapabilityTool over the capabilityd socket |
| Agent endpoints | internal/admind/agent_drive.go, forwarding to Blueclaw through blueclawJSONRequest |
| Wire types | pkg/capabilityprotocol/protocol.go |
| Tool catalog | pkg/capabilityprotocol/generated/capability-tools.json |
| The contract, stated as tests | web/tests/integration/public-api-route.test.ts, internal/admind/public_tool_gateway_test.go |
MCP authorization: the resource metadata and the 401 challenge, then consent | web/src/lib/server/public-api/protected-resource.ts, web/src/routes/oauth/consent/, [auth.oauth_server] in supabase/config.toml |
OpenAPI document and the /api-docs redirect | docs/web/app/lib/openapi.ts, web/src/lib/server/api-reference-redirect.ts |