Plan — To Do
Extend the Rojo Chat agent with a built-in tool set — file system read/write operations the model can call during a conversation. Generic across any OpenAI-compatible provider (Claude, Gemini, Mistral, local models).
LLM tool use (also called function calling) lets a model request that the server run a named function and return the result, then continue generating text using that result as context. The model never executes code directly — it emits a structured tool call, the server runs it, and the result is fed back as a message.
For Rojo Chat this unlocks agentic workflows: a user can ask the Rojo to
"create a new page at docs/intro and add a heading and some intro text" and the
model will call create_directory, then write_text_content,
then reply with a summary — all in a single conversation turn.
The tool format used is the OpenAI function-calling standard, which is supported
by all major providers at their OpenAI-compatible endpoints:
Claude (api.anthropic.com/v1),
Gemini (generativelanguage.googleapis.com/v1beta/openai/),
Mistral (api.mistral.ai/v1), and local models via Ollama or LM Studio.
No provider-specific branching is needed in the implementation.
All tools operate on the current project's file system, scoped to
storage/<projectId>/root/. Paths are always relative to
the project root. Access checks already in the chat route ensure only authorised
users can trigger tool execution.
Get metadata for a path — whether it is a file or directory, size in bytes, and last-modified time.
{
"name": "read_path_meta",
"description": "Get metadata for a file or directory: type (file/directory), size in bytes, and last-modified time.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}
Returns: { type: "file"|"directory", size: number, modifiedAt: string }
Server implementation: fs.stat()
Read and return the text content of a file.
{
"name": "get_text_content",
"description": "Read and return the text content of a file.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}
Returns: file content as a string.
Server implementation: readProjectFile() — already in the storage layer.
Write text to a file, creating it if it does not exist or overwriting it if it does.
{
"name": "write_text_content",
"description": "Write text content to a file, creating or overwriting it.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." },
"text": { "type": "string", "description": "The text content to write." }
},
"required": ["path", "text"]
}
}
Returns: { ok: true } on success.
Server implementation: writeProjectFile() — already in the storage layer.
List the entries (files and subdirectories) inside a directory.
{
"name": "list_files",
"description": "List the files and subdirectories inside a directory.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}
Returns: { name: string, type: "file"|"directory" }[]
Server implementation: fs.readdir(dir, { withFileTypes: true })
Create a directory (and any missing parent directories) at the given path.
{
"name": "create_directory",
"description": "Create a directory and any missing parent directories at the given path.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the project root." }
},
"required": ["path"]
}
}
Returns: { ok: true } on success.
Server implementation: fs.mkdir(path, { recursive: true })
The current RojosAgent.ts calls model.stream(messages)
once and streams text back. With tools, the agent must loop until the model
produces a final text reply:
loop:
stream from model.bindTools(tools)
collect full response
if response contains tool calls:
for each tool call:
→ execute tool on server
→ emit TOOL_CALL event to client
→ emit TOOL_RESULT event to client
add AIMessage (tool calls) + ToolMessages (results) to history
continue loop
if response is text:
stream text chunks to client as CHAT events
add AIMessage to history
break
getAgentStream in RojosAgent.ts becomes an
async function* generator that yields typed events instead of
raw LangChain chunks. The route handler in rojos.ts iterates
the generator and serialises each event to the wire protocol.
{ type: "text", text: string }
{ type: "tool_call", name: string, args: Record<string, unknown> }
{ type: "tool_result", name: string, result: string }
These map to the existing NDJSON wire protocol in rojos.ts:
{ "type": "CHAT", "text": "..." } // unchanged
{ "type": "TOOL_CALL", "name": "...", "args": {...} } // new
{ "type": "TOOL_RESULT","name": "...", "result": "..." } // new
{ "type": "DONE" } // unchanged
Every tool execution receives a RojoToolContext carrying the
projectId and the resolved projectRoot path
(storage/<projectId>/root/). Tools use this to scope
all file system operations — no tool ever receives an absolute path from
the model.
Tools live in their own directory. Each tool is a self-contained class so it is easy to review, test, and extend independently. A registry class owns the full list and dispatches execution by name.
source/server/rojos/
RojosAgent.ts — modified: generator loop, tool binding
RojosConfig.ts — unchanged
tools/
RojoTool.ts — abstract base class + RojoToolContext interface
RojoToolRegistry.ts — holds all tool instances, dispatches execute()
ReadPathMetaTool.ts
GetTextContentTool.ts
WriteTextContentTool.ts
ListFilesTool.ts
CreateDirectoryTool.ts
export interface RojoToolContext
{
projectId: string;
projectRoot: string;
}
export abstract class RojoTool
{
abstract Definition: object;
abstract execute( args: Record<string, unknown>, ctx: RojoToolContext ): Promise<unknown>;
}
export class RojoToolRegistry
{
static readonly All: RojoTool[] = [
new ReadPathMetaTool(),
new GetTextContentTool(),
new WriteTextContentTool(),
new ListFilesTool(),
new CreateDirectoryTool(),
];
static definitions(): object[]
{
return RojoToolRegistry.All.map( t => t.Definition );
}
static execute( name: string, args: Record<string, unknown>, ctx: RojoToolContext )
{
const tool = RojoToolRegistry.All.find( t => ( t.Definition as any ).function?.name === name );
if ( !tool ) return Promise.resolve( { error: `Unknown tool: ${ name }` } );
return tool.execute( args, ctx );
}
}
AgentConfig gets an optional tools: object[] field.getAgentStream becomes an async function* generator
yielding typed events instead of raw LangChain chunks.config.tools is non-empty, the model is created with
model.bindTools(config.tools) and the loop handles tool call chunks.updateAgentConversation receives the full collected
AIMessage (including tool calls) and the subsequent
ToolMessage array so history stays consistent.RojoToolRegistry.definitions() into AgentConfig.tools
when a projectId is present (so the tool context can be resolved).RojoToolContext into the generator so tools can be executed.tool_call and tool_result event types
to the existing NDJSON wire format.TOOL_CALL messages: renders a small dim activity line
showing the tool name and arguments (collapsed by default).TOOL_RESULT messages: appends the result status (ok / error)
to the same activity line.After implementation, the following single-message conversation should work end-to-end with any capable model via an external or tunnel endpoint:
User: "Create a directory at docs/intro and write an index.page
file there with a page header and a short intro paragraph."
Expected sequence:
⚙ create_directory("docs/intro") → ok
⚙ write_text_content("docs/intro/index.page", "...") → ok
Assistant: "Done. I created docs/intro and wrote index.page with ..."
This test exercises two sequential tool calls in one agent loop iteration, confirming that history is correctly updated between calls and that the model can compose multiple operations from a single natural-language instruction.
source/server/rojos/tools/ directoryRojoTool.ts (abstract base + context interface)ReadPathMetaTool, GetTextContentTool,
WriteTextContentTool, ListFilesTool, CreateDirectoryToolRojoToolRegistry.tsgetAgentStream as an async generator with the tool execution loopPOST /chat routeTOOL_CALL and TOOL_RESULT message types in
rojo-chat-panel.ts