LLM Tools¶
Permission required: tools
Register tools (function calling) that LLM providers can invoke during generation. Tools can also be made available as Council tools, allowing users to assign them to Council members for pre-generation analysis.
Registering a Tool¶
spindle.registerTool({
name: 'search_knowledge_base',
display_name: 'Search Knowledge Base',
description: 'Searches the extension knowledge base for relevant information',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results', default: 5 },
},
required: ['query'],
},
council_eligible: true,
})
// Unregister
spindle.unregisterTool('search_knowledge_base')
ToolRegistrationDTO¶
| Field | Type | Description |
|---|---|---|
name |
string |
Unique tool identifier (bare name — no colons) |
display_name |
string |
Human-readable name shown in the Council tools list |
description |
string |
Description for the LLM. Used in function calling and as the tool prompt for Council sidecar mode |
parameters |
JSONSchema |
JSON Schema defining the tool's input arguments |
council_eligible |
boolean |
Optional. When true, the tool appears in the Council tools list and can be assigned to Council members. Default: false |
The extension_id field is set automatically by the host — you don't need to provide it.
Council Tool Integration¶
When council_eligible: true, your tool appears in the user's Council panel alongside built-in tools. Users can assign it to any Council member. During generation, if the member is active (passes their dice roll), your tool is invoked.
How tools are invoked¶
Tools execute differently depending on the Council mode (configured by the user):
| Mode | How your tool runs |
|---|---|
| Sidecar (default) | A separate sidecar LLM reads your tool's description as a prompt and generates a text response. Your extension is not called — sidecar tools use the LLM, not your code. |
| Inline | Your tool definition is sent as a function-call schema to the primary LLM. The LLM decides when to invoke it. |
Extension tools always route to your worker
Unlike built-in/DLC tools (which are pure LLM prompts), extension-registered tools are always invoked via your worker — even in sidecar mode. The host sends a tool_invocation message to your worker with the chat context, and your code returns the result.
Handling tool invocations¶
When your tool is invoked during Council execution, the host sends a TOOL_INVOCATION event to your worker:
spindle.on('TOOL_INVOCATION', async (payload) => {
const { toolName, args, councilMember, contextMessages } = payload
if (toolName === 'search_knowledge_base') {
const results = await searchMyKnowledgeBase(args.query, args.limit)
// Inspect the structured chat context if you need role boundaries
const lastAssistant = contextMessages
?.filter(m => m.role === 'assistant')
.pop()
// When invoked via council, tailor the output to the assigned member's voice
if (councilMember) {
return `${councilMember.name} (${councilMember.role || 'analyst'}) reports:\n` +
results.map(r => r.summary).join('\n')
}
return results.map(r => r.summary).join('\n')
}
return 'Unknown tool'
})
The return value is a string that becomes the tool's result in the Council deliberation block (visible to the main LLM during generation).
Tool naming¶
Extension tools are stored internally with a qualified name: extensionId:toolName. When a user assigns your tool to a Council member, the qualified name is used. You don't need to worry about collisions with other extensions' tools.
Invocation payload¶
The handler receives a ToolInvocationPayloadDTO:
| Field | Type | Description |
|---|---|---|
toolName |
string |
The bare name you registered (no extensionId: qualifier) |
args |
Record<string, unknown> |
Arguments matching your tool's parameters schema, plus the host-supplied fields below |
requestId |
string |
Host-side correlation id for this invocation |
councilMember |
CouncilMemberContext \| undefined |
Assigned member snapshot when invoked via council — see below. Undefined for non-council invocation paths |
contextMessages |
LlmMessageDTO[] \| undefined |
Structured chat context for council invocations — the same content as the flattened args.context string, but with role boundaries preserved. See below. Undefined for non-council invocation paths |
Host-supplied fields inside args for council invocations:
| Field | Type | Description |
|---|---|---|
context |
string |
Formatted chat context (character info, world info, recent messages) — the same context sidecar tools see. Kept for backwards compatibility; use contextMessages (top-level) when you need role boundaries |
__deadlineMs |
number |
Timestamp by which the tool must respond (derived from timeoutMs setting) |
No __userId in args
The worker host strips __userId, __user_id, and userId from args before delivering the invocation. Extensions identify their owner via their worker context, not a string parameter. Any userId you receive in args would be untrusted; don't rely on one being present.
Council member context¶
When a tool is invoked as part of a council cycle, the host attaches a councilMember snapshot of the assigned member. Use it to personalise your tool's response in the member's voice, filter by role, or surface the avatar in modal UI.
interface CouncilMemberContext {
memberId: string // Council settings row id
itemId: string // Backing Lumia item id
packId: string // Pack the item lives in
packName: string // Pack display name
name: string // Member / Lumia item name
role: string // User-assigned role (e.g. "Plot Enforcer")
chance: number // Participation probability 0–100
avatarUrl: string | null // Relative URL (e.g. /api/v1/images/{id})
definition: string // Lumia "definition" field
personality: string // Lumia "personality" field
behavior: string // Lumia "behavior" field
genderIdentity: 0 | 1 | 2 | 3 // 0=feminine, 1=masculine, 2=neutral, 3=any
}
The context is built entirely host-side from the user's council settings row and the backing Lumia item. It is delivered as a separate top-level field on the payload so user-space args cannot collide with or spoof it. councilMember is undefined for any non-council invocation path (future inline function calling, etc.) — guard on presence before reading.
Structured context messages¶
Council invocations also deliver the assembled chat context as a structured contextMessages: LlmMessageDTO[] field. This is the same content that populates args.context (kept for backwards compatibility), but with role boundaries preserved so you can filter by role, extract the last user/assistant turn, or re-render the context in your own format.
interface LlmMessageDTO {
role: 'system' | 'user' | 'assistant'
content: string
name?: string
}
Multi-part message content (multimodal text+image/audio parts) is flattened to its text portion before being forwarded — non-text parts are dropped.
Like councilMember, contextMessages is delivered as a separate top-level payload field so it cannot collide with or be spoofed by user-space args. It is undefined for any non-council invocation path — guard on presence before reading.
Tool lifecycle¶
- Tools are registered when your extension loads (
spindle.registerTool()) - Tools are automatically unregistered when your extension stops or unloads
- If the
toolspermission is revoked, registration silently fails and apermission_deniedevent fires
Sidecar LLM¶
Council tools, expression detection, and other background LLM features share a sidecar LLM connection configured by the user in the Council panel under "Sidecar LLM". This is independent of the user's main generation connection.
The sidecar connection is stored as the sidecarSettings user setting:
interface SidecarConfig {
connectionProfileId: string // FK to a connection profile
model: string // Model override
temperature: number // Default: 0.7
topP: number // Default: 0.9
maxTokens: number // Default: 1024
}
Your extension doesn't need to interact with sidecar settings directly — tool invocations are routed through the host, which handles connection resolution. If you need to fire your own LLM calls, use spindle.generate.quiet() or spindle.generate.raw() with the generation permission instead.