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:
| Field | Says |
|---|---|
inputSchema | the JSON Schema your input must satisfy |
outputSchema | the shape of result on success |
sideEffectClass | which permission the call requires |
idempotency | whether idempotencyKey means anything for this tool |
resultContract.effects | which effects entries a successful call promises |
availability | whether 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" }
}| Field | Meaning |
|---|---|
provider, selectedBackend | where the call ran: device or remote |
outcome | succeeded, failed, or denied |
status | a tool-specific status string |
effects | the record of what changed |
result | the tool's output, matching its outputSchema |
isError, message, errorCode, failureStage | failure detail |
retryable, safeRetry | whether 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:
- an exact identifier or an exact title resolves;
- a title only one candidate contains resolves, ignoring case and spacing;
- a title several contain resolves only when exactly one of them is the caller's own task, and is otherwise a question;
- 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
| Concern | Where |
|---|---|
| List and read handlers | discoveryAnswer in web/src/routes/api/v1/[...path]/+server.ts, over web/src/lib/server/public-api/catalog.ts |
| Invoke handler | invokePublicTool in internal/admind/public_tool_gateway.go, reached through the company's relay |
Descriptor fetch from capabilityd | fetchCapabilityRegistry over the unix socket, read in exposableCapabilityDescriptors |
| The actor override | invokePublicTool sets Actor, Context, PrivacyClass, RequiresUserPresence after decoding your body |
| The approval-continuation flag | publicToolInvokeContext, keyed on publicToolPermissionForDescriptor |
| Request and response types | ToolInvokeRequest, ToolInvokeResponse, ResourceEffect in pkg/capabilityprotocol/protocol.go |
| Input and output schemas | pkg/capabilityprotocol/generated/capability-tools.json |