Skip to content

From Theory to Production: Building Bounded Context Packs

Professor Synapse
Professor Synapse

Series: Bounded Context Packs (Part 3 of 4)

"Architecture is frozen music, but code is frozen problem-solving."

Article 1 and Article 2 established the problem and the pattern. Now let's open the hood.

This article walks through a production implementation of Bounded Context Packs: Nexus, an open-source Obsidian plugin that runs MCP tools through a meta-layer architecture. Every code example comes from running production code.


The Two-Tool Architecture

The entire BCP implementation pivots on two tools: getTools and useTools.

Instead of registering 33 tools with the server, we register exactly 2. The model discovers available tools via getTools, then executes them via useTools with unified context.

// src/agents/toolManager/toolManager.ts

/**
 * ToolManager Agent
 * Provides the two-tool architecture: getTools and useTools
 *
 * This agent consolidates all tool access through a unified interface,
 * enforcing context-first design and reducing token usage.
 */
export class ToolManagerAgent extends BaseAgent {
  private app: App;
  private allAgents: Map<string, IAgent>;

  constructor(app: App, agentRegistry: Map<string, IAgent>, schemaData?: SchemaData) {
    super(
      'toolManager',
      'Discover and execute tools across all agents with unified context',
      '1.0.0'
    );

    this.app = app;
    this.allAgents = agentRegistry;

    // Register the TWO tools - this is all that gets exposed to Claude Desktop
    this.registerTool(new GetToolsTool(agentRegistry, schemaData));
    this.registerTool(new UseToolTool(app, agentRegistry, schemaData.workspaces));
  }
}

The Key Insight: Instead of 33 tool schemas (~8,000+ tokens), the model sees just 2 schemas (~600 tokens). A 92% reduction in upfront context usage.


GetTools: Discovery Without Overhead

The getTools tool's description contains the complete capability index. The model sees every available agent and tool just by seeing the tool definition:

// src/agents/toolManager/tools/getTools.ts

export class GetToolsTool implements ITool<GetToolsParams, GetToolsResult> {
  slug = 'getTools';
  name = 'Get Tools';
  version = '1.0.0';

  private agentRegistry: Map<string, IAgent>;

  constructor(agentRegistry: Map<string, IAgent>, schemaData: SchemaData) {
    this.agentRegistry = agentRegistry;

    // Build description dynamically from actual registered agents
    this.description = this.buildDescription(schemaData);
  }

  /**
   * Build the dynamic description from actual registered agents
   * This is the "menu" the model sees without needing a discovery call
   */
  private buildDescription(schemaData: SchemaData): string {
    const lines = [
      'Get parameter schemas for specific tools.',
      ''
    ];

    // Build from actual registered agents (single source of truth)
    lines.push('Agents:');
    for (const [agentName, agent] of this.agentRegistry) {
      if (agentName === 'toolManager') continue;
      const tools = agent.getTools().map(t => t.slug);
      if (tools.length > 0) {
        lines.push(`${agentName}: [${tools.join(',')}]`);
      }
    }

    // Include workspaces and vault structure
    lines.push('');
    lines.push('Workspaces: [default' +
      (schemaData.workspaces.length > 0
        ? ',' + schemaData.workspaces.map(w => w.name).join(',')
        : '') + ']');

    return lines.join('\n');
  }
}

At runtime, this produces something like:

Get parameter schemas for specific tools.

Agents:
canvasManager: [read,write,update,list]
contentManager: [read,update,write]
memoryManager: [createState,listStates,loadState,archiveWorkspace,...]
promptManager: [archivePrompt,createPrompt,executePrompts,...]
searchManager: [searchContent,searchDirectory,searchMemory]
storageManager: [archive,copy,createFolder,list,move,open]

Workspaces: [default,research,content creation]

Result: 33 capabilities. One schema description. ~500 tokens at startup instead of ~7,000.


The Context Schema: Memory-Goal-Constraints

Every tool call flows through a unified context schema that captures session state:

// src/agents/toolManager/types.ts

/**
 * Shared JSON schema for ToolContext
 * Used by both getTools and useTools parameter schemas
 */
export function getToolContextSchema(): Record<string, unknown> {
  return {
    type: 'object',
    properties: {
      workspaceId: {
        type: 'string',
        description: 'Workspace ID. Use "default" for global workspace.'
      },
      sessionId: {
        type: 'string',
        description: 'Session identifier - provide any name, a standard ID will be assigned.'
      },
      memory: {
        type: 'string',
        description: 'Essence of conversation so far (1-3 sentences)'
      },
      goal: {
        type: 'string',
        description: 'Current objective (1-3 sentences)'
      },
      constraints: {
        type: 'string',
        description: 'Rules/limits to follow (optional, 1-3 sentences)'
      }
    },
    required: ['workspaceId', 'sessionId', 'memory', 'goal'],
    description: 'Context for session tracking. Fill this FIRST before other parameters.'
  };
}

Context-First Design: By requiring memory and goal on every call, we capture the model's reasoning state for every trace. This becomes searchable memory.

Synaptic Labs AI education attribution required

Schema Retrieval: Request What You Need

When getTools is called, it returns only the schemas requested:

// src/agents/toolManager/tools/getTools.ts

async execute(params: GetToolsParams): Promise<GetToolsResult> {
  const { request } = params;
  const resultSchemas: ToolSchema[] = [];

  // Process each request item in the array
  for (const item of request) {
    const agentName = item.agent;
    const toolNames = item.tools;

    const agent = this.agentRegistry.get(agentName);
    if (!agent) {
      continue; // Skip unknown agents
    }

    // Get specific tools
    for (const toolSlug of toolNames) {
      const tool = agent.getTool(toolSlug);
      if (!tool) continue;

      // Build schema and strip common parameters
      const schema = this.buildToolSchema(agentName, tool);
      resultSchemas.push(schema);
    }
  }

  return {
    success: true,
    data: { tools: resultSchemas }
  };
}

The Token-Saving Trick: Schema Stripping

Every tool shares common context parameters. Instead of repeating them in every schema, we strip them out:

// src/agents/toolManager/tools/getTools.ts

/**
 * Strip common parameters from schema that are handled by useTools
 * Saves ~200 tokens per tool by removing redundant context definitions
 */
private stripCommonParams(schema: Record<string, unknown>): Record<string, unknown> {
  const result = { ...schema };

  // Remove common params from properties
  if (result.properties && typeof result.properties === 'object') {
    const props = { ...(result.properties as Record<string, unknown>) };
    delete props.context;
    delete props.workspaceContext;
    result.properties = props;
  }

  // Remove from required array if present
  if (result.required && Array.isArray(result.required)) {
    result.required = result.required.filter(
      (r: string) => r !== 'context' && r !== 'workspaceContext'
    );
  }

  return result;
}

Why This Matters: Without stripping, a 5-tool request would include ~1,000 tokens of redundant context definitions. With stripping, context is defined once at the useTools level.


UseTools: Unified Execution

The useTools tool handles all execution with context validation:

// src/agents/toolManager/tools/useTools.ts

export class UseToolTool implements ITool<UseToolParams, UseToolResult> {
  slug = 'useTools';
  name = 'Use Tools';
  description = 'Execute tools with context. Fill context FIRST.';

  async execute(params: UseToolParams): Promise<UseToolResult> {
    // Validate context FIRST
    const contextErrors = this.validateContext(params.context);
    if (contextErrors.length > 0) {
      return {
        success: false,
        error: `Invalid context: ${contextErrors.join(', ')}`
      };
    }

    // Validate workspaceId exists
    const workspaceError = await this.validateWorkspaceId(params.context.workspaceId);
    if (workspaceError) {
      return { success: false, error: workspaceError };
    }

    // Execute based on strategy (serial or parallel)
    const strategy = params.strategy || 'serial';
    let results: ToolCallResult[];

    if (strategy === 'parallel') {
      results = await this.executeParallel(params.context, params.calls);
    } else {
      results = await this.executeSerial(params.context, params.calls);
    }

    // Format and return results
    return this.formatResults(results);
  }
}

Self-Registering Agents: Domain as Code

Agents register themselves and their tools at construction time:

// src/agents/contentManager/contentManager.ts

/**
 * Agent for content operations in the vault
 * Simplified from 8 tools to 3 tools following CRUA pattern
 *
 * Tools:
 * - read: Read content from files with explicit line ranges
 * - write: Create new files or overwrite existing files
 * - update: Insert, replace, delete, append, or prepend content
 */
export class ContentManagerAgent extends BaseAgent {
  protected app: App;

  constructor(app: App, plugin?: NexusPlugin) {
    super(
      'contentManager',
      'Content operations for Obsidian notes',
      '1.0.0'
    );

    this.app = app;

    // Register simplified tools (3 tools replacing 8)
    this.registerTool(new ReadTool(app));
    this.registerTool(new WriteTool(app));
    this.registerTool(new UpdateTool(app));
  }
}

Zero Configuration Discovery: When getTools builds the capability index, it calls agent.getTools() on each registered agent. Add a tool to any agent's constructor, and it automatically appears in discovery. No config files to update.


A Complete Request Flow

Let's trace a real request through the system:

Step 1: Model sees getTools schema (startup)

Agents:
contentManager: [read,update,write]
searchManager: [searchContent,searchDirectory,searchMemory]
storageManager: [list,createFolder,move,copy,archive,open]
...

Step 2: Model requests specific tools

{
  "name": "getTools",
  "arguments": {
    "context": {
      "workspaceId": "default",
      "sessionId": "research-session",
      "memory": "User asked to read their research notes",
      "goal": "Retrieve contents of research file"
    },
    "request": [
      { "agent": "contentManager", "tools": ["read"] },
      { "agent": "searchManager", "tools": ["searchContent"] }
    ]
  }
}

Step 3: System returns clean schemas

{
  "success": true,
  "data": {
    "tools": [
      {
        "agent": "contentManager",
        "tool": "read",
        "description": "Read content from a file with line range",
        "inputSchema": {
          "type": "object",
          "properties": {
            "path": { "type": "string" },
            "startLine": { "type": "number" },
            "endLine": { "type": "number" }
          },
          "required": ["path", "startLine"]
        }
      }
    ]
  }
}

Step 4: Model calls useTools

{
  "name": "useTools",
  "arguments": {
    "context": {
      "workspaceId": "default",
      "sessionId": "research-session",
      "memory": "User asked to read their research notes. Got tool schemas.",
      "goal": "Read the research.md file starting from line 1"
    },
    "calls": [
      {
        "agent": "contentManager",
        "tool": "read",
        "params": {
          "path": "notes/research.md",
          "startLine": 1
        }
      }
    ]
  }
}

Step 5: UseTools dispatches to agent/tool

// Inside UseToolTool.executeCall()
const agent = this.agentRegistry.get("contentManager");
const toolInstance = agent.getTool("read");

// Execute with ONLY tool-specific params (no context)
const result = await toolInstance.execute({ path: "notes/research.md", startLine: 1 });

Step 6: Tool executes and returns

{
  "success": true,
  "data": {
    "content": "1: # Research Notes\n2: \n3: Findings from today...",
    "path": "notes/research.md",
    "startLine": 1,
    "endLine": 50
  }
}

Total tool schemas loaded: 2 (not 33)


What This Enables

Benefit Explanation
Local model compatibility 33 tools x ~250 tokens = 8,250 tokens. An 8K context model is immediately overwhelmed. With BCPs: 600 tokens at startup + ~150 tokens per tool. A 5-tool task fits easily.
Cost efficiency API providers charge per token. Every request that loads all 33 tools pays for 33 tools. With BCPs, you pay for what you use.
Cognitive clarity The model isn't choosing between 33 options. It's choosing between 6 domains, then specific operations within the relevant domain.
Extension without bloat Adding new agents or tools doesn't increase startup cost. The meta-layer absorbs new capabilities without growing the initial context.

Key Implementation Files

Core Files:

  • src/agents/toolManager/toolManager.ts - ToolManager agent (the two-tool entry point)
  • src/agents/toolManager/tools/getTools.ts - Discovery tool
  • src/agents/toolManager/tools/useTools.ts - Execution tool
  • src/agents/toolManager/types.ts - ToolContext schema
  • src/agents/baseAgent.ts - Agent base class
  • src/agents/baseTool.ts - Tool base class
  • src/server/MCPServer.ts - MCP server orchestration

Agent Implementations:

  • src/agents/canvasManager/canvasManager.ts (4 tools)
  • src/agents/contentManager/contentManager.ts (3 tools)
  • src/agents/storageManager/storageManager.ts (6 tools)
  • src/agents/searchManager/searchManager.ts (3 tools)
  • src/agents/memoryManager/memoryManager.ts (8 tools)
  • src/agents/promptManager/promptManager.ts (9 tools)

What's Next

This article showed the implementation. Article 4 covers what emerged from production use:

  • Batch operations: When single-tool calls aren't enough
  • Session context: How memory flows through the system
  • Cross-domain routing: When a task spans multiple agents
  • The patterns that only appear under load

The theory worked. The implementation runs. Now let's talk about what happens when real users push the system.


Previous: The Meta-Tool Pattern

Next: The Tool Bloat Tipping Point

Share this post