Plan — To Do

Rojo Chat — LLM Tools

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).

What it is

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.

Built-in Tools — First Set

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.

read_path_meta

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()

get_text_content

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_content

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_files

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_directory

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 Tool Execution Loop

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.

Event types emitted by the generator

{ 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

Tool context

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.

File Structure

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

RojoTool base class

export interface RojoToolContext
{
  projectId:   string;
  projectRoot: string;
}

export abstract class RojoTool
{
  abstract Definition: object;
  abstract execute( args: Record<string, unknown>, ctx: RojoToolContext ): Promise<unknown>;
}

RojoToolRegistry

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 );
  }
}

Changes to Existing Files

source/server/rojos/RojosAgent.ts

  • AgentConfig gets an optional tools: object[] field.
  • getAgentStream becomes an async function* generator yielding typed events instead of raw LangChain chunks.
  • When 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.

source/server/routes/rojos.ts — POST /chat

  • Passes RojoToolRegistry.definitions() into AgentConfig.tools when a projectId is present (so the tool context can be resolved).
  • Passes a RojoToolContext into the generator so tools can be executed.
  • Serialises the new tool_call and tool_result event types to the existing NDJSON wire format.

source/components/rojo-chat-panel/rojo-chat-panel.ts

  • Handles TOOL_CALL messages: renders a small dim activity line showing the tool name and arguments (collapsed by default).
  • Handles TOOL_RESULT messages: appends the result status (ok / error) to the same activity line.
  • Visual style: compact, clearly distinct from user and assistant bubbles — not the focus, just informational.

Verification Test

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.

Phased Implementation

Phase 1 — Backend tools + loop

  • Create source/server/rojos/tools/ directory
  • Write RojoTool.ts (abstract base + context interface)
  • Write one class per tool: ReadPathMetaTool, GetTextContentTool, WriteTextContentTool, ListFilesTool, CreateDirectoryTool
  • Write RojoToolRegistry.ts
  • Rewrite getAgentStream as an async generator with the tool execution loop
  • Wire tools into the POST /chat route

Phase 2 — Frontend display

  • Handle TOOL_CALL and TOOL_RESULT message types in rojo-chat-panel.ts
  • Render compact activity lines for each tool call / result

Phase 3 — Future extensions

  • Per-rojo tool configuration: enable/disable specific tools per rojo
  • Additional tools: rename, delete, move files
  • User-defined tools: name + description + server-side handler registration