internkim
API

Invoking tools

List the tools, call one, and read what actually changed.

Listing tools

curl https://api.intern.kim/v1/tools \
  --header "Authorization: Bearer $INTERNKIM_TOKEN"

The response is {"tools": [...]}, holding the full descriptor of every tool the key's permission can call, and a catalog field saying which list you got. The base catalog is answered without asking the company machine, so it is the fast one and it is the same for everyone; it is also documented on the tool catalog page. Whether a tool can run right now is known only on the machine, and a company may carry tools the base list does not. Ask for those with ?live=true, which costs the round trip. X-INTERNKIM-CATALOG says base on either discovery answer.

Reading a descriptor

curl https://api.intern.kim/v1/tools/task_add \
  --header "Authorization: Bearer $INTERNKIM_TOKEN"

A tool the token cannot call answers 404 here, as if it did not exist. The descriptor fields worth reading before a call:

FieldSays
inputSchemathe JSON Schema your input must satisfy
outputSchemathe shape of result on success
sideEffectClasswhich permission the call requires
idempotencywhether idempotencyKey means anything for this tool
resultContract.effectswhich effects entries a successful call promises
availabilitywhether the tool can run right now, and why not

Invoking

curl https://api.intern.kim/v1/tools/task_add/invoke \
  --header "Authorization: Bearer $INTERNKIM_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"input":{"title":"draft the quarterly report","size":"M"}}'

The body carries input and, where supported, idempotencyKey. Anything else you send for actor or context is overwritten from the token before admind invokes the tool; the test TestPublicToolGatewayOverridesActorFromBearerToken pins this down.

input is read against that tool's own inputSchema before anything runs, whether the record answers the call or it is carried to the company's computer. A value outside an enum, a field the schema does not carry, or a required field left out is refused 400 naming the field:

{ "message": "input.size: Invalid option: expected one of \"XS\"|\"S\"|\"M\"|\"L\"|\"XL\"|\"XXL\"" }

Saying a field has no value

There are three ways to say an optional field has no value, and they are one request: leave the field out, write null, or write "". All three are read as the field being left out, so a caller that fills every field it knows about is not refused for the way it spelled an empty one.

{ "input": { "title": "draft the quarterly report", "startsAt": "", "size": null } }

That call is the same as sending title alone.

This applies only where the schema allows the field to be left out. A field the tool requires is refused when you send it empty, because "you sent nothing" and "you forgot this" need different answers:

{ "message": "input.title: Invalid input: expected string, received undefined" }

No approval pause

In a messenger conversation the agent stops and asks before a delete or a send. Every non-read invocation arriving this way is marked an already-approved continuation, so the same tools act immediately here. The permission on the token is the consent: a delete token deletes without a question.

The response

A tool that runs on the record — task, calendar, people, leave and attendance — answers { "tool": ..., "result": ... }, because the plane ran it itself. A historical or administrator attendance write also carries notified, saying who was told about it or which administrators were asked. The live self-clock path has no notified field; it saves first, then starts notifications in the background. An asked response has no event. task_add is one of these:

{
  "tool": "task_add",
  "result": {
    "taskID": "b2c0…",
    "content": "draft the quarterly report",
    "ownerID": "8f31…",
    "ownerName": "Sample Lee",
    "participantIDs": ["8f31…"],
    "participantNames": ["Sample Lee"],
    "participantPresentations": [
      {
        "personID": "8f31…",
        "displayName": "Sample Lee",
        "email": "[email protected]",
        "mention": "@Sample Lee"
      }
    ],
    "business": "Sales",
    "type": "",
    "size": "M",
    "status": "planned",
    "startDate": "",
    "endDate": "",
    "weekCode": "",
    "canDelete": true,
    "canUpdate": true
  }
}

For attendance_add, omit personHint, date, and time to clock the authenticated member at the current moment. The web request carries the user's JWT, and an ik_ personal API token is converted server-side to that member's JWT before the database call, so attendance is written with member authority and never with the service key. The result keeps the existing status, eventID, and backdated fields and adds the saved event with id, personID, kind, occurredAt, and location. Treat the returned time and location as authoritative for the UI without refreshing the list.

attendance_team_page_get reads either a bounded page of team cards or a searched, bounded employee page within one selected team. It derives the company from the authenticated member, enforces the team-view setting, and returns status, recent real clock actors and recorded locations without month history. Its teamLimit and memberLimit are capped at 24 and 48. The monthly comparison and an individual member's period history are separate requests.

Every field here is the tool's own outputSchema, which the descriptor carries and the record answer is held to: nothing else may appear, and the record's answer is checked against it before it leaves. A task's title is content, and the people on it come three ways — participantIDs to act on, participantNames to print, and participantPresentations to address someone. canDelete is the record's answer to whether the caller may delete the task: an admin may, the member who asked for it may, and so may the one person on it when nobody else is. canUpdate answers whether the caller may change the task or who is on it, which anybody on it may do as well.

A tool carried to the company's computer answers the fuller shape below — message_send is one of these:

{
  "provider": "internkim",
  "selectedBackend": "device",
  "toolName": "message_send",
  "outcome": "succeeded",
  "status": "sent",
  "effects": [
    { "objectType": "message", "effect": "sent", "id": "..." }
  ],
  "result": { "messageIDs": ["..."], "deliveryStatus": "sent" }
}
FieldMeaning
provider, selectedBackendwhere the call ran: device or remote
outcomesucceeded, failed, or denied
statusa tool-specific status string
effectsthe record of what changed
resultthe tool's output, matching its outputSchema
isError, message, errorCode, failureStagefailure detail
retryable, safeRetrywhether a retry is sensible, and whether it is safe

Looking before a destructive call

POST /v1/tools/<name>/target answers what a call would touch, without making it. It takes the same input the call takes, and it is refused for a tool whose sideEffectClass is not destructive. There is nothing to look at first.

curl https://api.intern.kim/v1/tools/event_delete/target \
  --header "Authorization: Bearer $INTERNKIM_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"input":{"eventHint":"주간 회의"}}'
{
  "tool": "event_delete",
  "target": {
    "inputField": "eventHint",
    "id": "b2c0…",
    "title": "주간 회의",
    "startsAt": "2026-09-03T10:00:00+09:00"
  }
}

The hint resolves through the same function the call itself uses, so the row looked at and the row touched cannot differ, and an unresolved hint answers here exactly what it would answer there — the same 409, the same candidates.

This is what a client asks before putting an irreversible action to a person. Replace the hint with the answer's target.id under target.inputField and the approved call acts on the row that was shown; resolving the hint a second time could land on a record that has moved. The agent's own approval prompt is built this way.

A destructive tool with nothing to resolve ahead answers "target": null.

Effects

effects is the record of what actually changed, written by the runtime from the tool's result contract. Each entry names an objectType, an effect (created, updated, deleted, ...), and one identity: an id, a path, or a url. Some entries add visibility, durability, filename, contentType, or a summary.

Where a call carries effects, verify through it and not the result text: an empty list means nothing changed. A record tool carries none, and its result is the whole answer.

Idempotency

idempotencyKey is honored only where the descriptor's idempotency.supported is true. In the base catalog that is message_send; everywhere else the key is carried but changes nothing. For agent messages, messageID deduplicates instead.

event_update and event_delete take an optional expectedUpdatedAt, the updatedAt the caller last read, and answer 409 with calendar_event_version_conflict and the event's current updatedAt when somebody else has changed it since.

Reading a task board

task_board_get takes boardWeek, a Monday as YYYY-MM-DD, and the same person scope as task_list. It returns the cards visible on that week and childProgress: parent IDs with direct-child totals and completed counts across all weeks. Stopped children are excluded; rejected children remain in the total. The board retains requested and paused work, current in-progress work, current undated or overdue plans, and dated plans and completed work for the selected week. Counts describe that selection. Complete history and lifetime metrics continue to use task_list.

This separate read keeps the closed task_list result compatible with existing clients. Both tools use the caller's task read permission and RLS-enabled client; the board adds no database RPC or alternative access path. Its card query omits notes, locations and unrelated CRM fields. Child counts read only children of the returned cards. The web app loads complete history for views that need it.

Choosing a target with a hint

Mutating tools take no separate ID parameter. task_update and task_delete take a taskHint: the exact task ID, or the exact current title as a task_list result shows it. Never the new or intended title. Person references work the same way: participantPersonHints takes names, @handles, or emails.

The server climbs one ladder:

  1. an exact identifier or an exact title resolves;
  2. a title only one candidate contains resolves, ignoring case and spacing;
  3. a title several contain resolves only when exactly one of them is the caller's own task, and is otherwise a question;
  4. nothing matching is approximated, and the nearest come back as a question.

Ownership on rung three is the only tie a hint breaks by itself, and it breaks in one direction: towards the task the caller owns, never towards somebody else's. A hint that leaves two of anyone else's rows standing is a question.

An identifier is never approximated: an ID one character off was invented rather than mistyped. An identifier several rows hold, such as two addresses sharing a local part and so one @handle, is a question. Nearness is measured the way that kind of value is actually got wrong: a name or a handle by a character or two, an address by its local part or its domain, a title by how much of it is shared, so a misspelling comes back with the nearest names.

An unresolved hint answers 409:

{
  "error": "박 matches more than one person",
  "errorCode": "interaction_required",
  "failureStage": "target_resolution",
  "retryable": true,
  "safeRetry": true,
  "hint": "박",
  "candidates": [
    { "id": "8f31…", "label": "박예시", "email": "[email protected]", "handle": "@yesi" }
  ]
}

errorCode is interaction_required when somebody has to be asked which one was meant, and task_not_found, calendar_event_not_found, person_not_found or task_participant_not_found when nothing came close. The candidates carry the identity to name one exactly on the retry: a candidate's handle is a value the next hint accepts, where a person's mention in a tool's result is the @name to write into a message, so the two are spelled differently and are not interchangeable. At most eight candidates come back. Nothing in the refusal tells you what to say: the wording is the caller's.

End to end

With a write token from Getting a token:

curl https://api.intern.kim/v1/tools/task_add/invoke \
  --header "Authorization: Bearer $INTERNKIM_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"input":{"title":"draft the quarterly report","size":"M"}}'

curl https://api.intern.kim/v1/tools/task_update/invoke \
  --header "Authorization: Bearer $INTERNKIM_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"input":{"taskHint":"draft the quarterly report","status":"in_progress"}}'

The first response's result carries the new taskID and "status":"planned"; the second answers the same taskID with "status":"in_progress". A record tool carries no effects, so the task itself, read back through result, is the confirmation. Deleting it takes a delete token:

curl https://api.intern.kim/v1/tools/task_delete/invoke \
  --header "Authorization: Bearer $INTERNKIM_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"input":{"taskHint":"draft the quarterly report"}}'

With only write, this is refused before the call leaves the plane at all, so nothing reaches the company's computer.

Where it is implemented

ConcernWhere
List and read handlersdiscoveryAnswer in web/src/routes/api/v1/[...path]/+server.ts, over web/src/lib/server/public-api/catalog.ts
Invoke handlerinvokePublicTool in internal/admind/public_tool_gateway.go, reached through the company's relay
Descriptor fetch from capabilitydfetchCapabilityRegistry over the unix socket, read in exposableCapabilityDescriptors
The actor overrideinvokePublicTool sets Actor, Context, PrivacyClass, RequiresUserPresence after decoding your body
The approval-continuation flagpublicToolInvokeContext, keyed on publicToolPermissionForDescriptor
Request and response typesToolInvokeRequest, ToolInvokeResponse, ResourceEffect in pkg/capabilityprotocol/protocol.go
Input and output schemaspkg/capabilityprotocol/generated/capability-tools.json

On this page