@singl/periphery
v0.1.2
Published
Peripheral nervous system for LLMs - afferent (Ψ observes) and efferent (~ acts) pathways
Readme
@agi/periphery
MCP server framework with eyes (discovery) and hands (action) for complete agency
Quick Start
# Install
bun add @agi/periphery @modelcontextprotocol/sdk
# Create your tools
# See examples/ directory
# Build and run
bun run build
node dist/examples/server.jsWhat is this?
A framework for building MCP (Model Context Protocol) servers using the eyes+hands pattern:
- 👁️ Eyes (Discovery): Observe state via S-expressions (read-only queries)
- 🤲 Hands (Action): Mutate state via batched actions (validated mutations)
Together they create complete agency: observe → decide → act → observe → ∞
Why both tools?
| With Discovery only | With Action only | With Both | |---------------------|------------------|-----------| | Can think | Can change | Can learn | | Observer | Blind actor | Complete agent | | ❌ Incomplete | ❌ Incomplete | ✅ Complete |
Installation
bun add @agi/periphery @modelcontextprotocol/sdk
# or
npm install @agi/periphery @modelcontextprotocol/sdkExample: Todo Server
Discovery Tool (Eyes)
import { DiscoveryToolInteraction } from "@agi/periphery";
import * as z from "zod";
export class TodoDiscoveryTool extends DiscoveryToolInteraction<{}> {
description = "Query todos using S-expressions";
async registerFunctions() {
this.registerFunction(
"get-all",
"Get all todos",
[],
async () => todos
);
this.registerFunction(
"filter-completed",
"Filter by completion status",
[z.boolean()],
async (completed) => todos.filter(t => t.completed === completed)
);
return async () => {}; // cleanup
}
}Query examples:
; Get all todos
(get-all)
; Get incomplete todos
(filter-completed false)
; Compositional query using Fantasy Land
(fmap (lambda (t) (get 'text t)) (filter-completed false))Action Tool (Hands)
import { ActionToolInteraction } from "@agi/periphery";
import { Context } from "hono";
import * as z from "zod";
export class TodoActionTool extends ActionToolInteraction<{}> {
description = "Mutate todo state";
contextSchema = {} as const;
constructor(context: Context) {
super(context);
this.registerAction({
name: "create",
description: "Create new todo",
props: {
id: z.string(),
text: z.string()
},
handler: async (context, { id, text }) => {
todos.push({ id, text, completed: false });
return { created: { id, text } };
}
});
this.registerAction({
name: "complete",
description: "Mark todo as completed",
props: {
id: z.string()
},
handler: async (context, { id }) => {
const todo = todos.find(t => t.id === id);
if (!todo) throw new Error(`Todo ${id} not found`);
todo.completed = true;
return { completed: todo };
}
});
}
}Action examples:
{
"actions": [
["create", "1", "Learn MCP"],
["create", "2", "Build tools"]
]
}{
"actions": [
["complete", "1"]
]
}Server Entry Point
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { MCPServerBridge } from "@agi/periphery";
import { TodoDiscoveryTool } from "./TodoDiscoveryTool.js";
import { TodoActionTool } from "./TodoActionTool.js";
async function main() {
const bridge = new MCPServerBridge(
{
name: "todo-server",
version: "1.0.0"
},
TodoDiscoveryTool, // Eyes
TodoActionTool // Hands
);
const transport = new StdioServerTransport();
await bridge.connect(transport);
console.error("✨ Todo server running");
}
main().catch(console.error);Complete Workflow Example
// 1. Create todos
{
"name": "TodoActionTool",
"arguments": {
"actions": [
["create", "1", "Learn MCP"],
["create", "2", "Build tools"],
["create", "3", "Deploy"]
]
}
}
// 2. Query all todos
{
"name": "TodoDiscoveryTool",
"arguments": {
"expr": "(get-all)"
}
}
// Returns: all 3 todos
// 3. Complete first todo
{
"name": "TodoActionTool",
"arguments": {
"actions": [["complete", "1"]]
}
}
// 4. Query incomplete todos
{
"name": "TodoDiscoveryTool",
"arguments": {
"expr": "(filter-completed false)"
}
}
// Returns: todos 2 and 3
// 5. Complete remaining todos
{
"name": "TodoActionTool",
"arguments": {
"actions": [
["complete", "2"],
["complete", "3"]
]
}
}
// 6. Verify all complete
{
"name": "TodoDiscoveryTool",
"arguments": {
"expr": "(filter-completed false)"
}
}
// Returns: [] (empty)API Reference
DiscoveryToolInteraction
Purpose: Read-only queries using S-expressions
abstract class DiscoveryToolInteraction<TContext> {
// Override these
abstract description: string;
abstract registerFunctions(context: TContext): Promise<() => Promise<void>>;
// Optional context schema
contextSchema?: Record<string, z.ZodType>;
// Register domain functions
protected registerFunction<T>(
name: string,
description: string,
params: T, // Zod schemas
handler: (...args: infer<T>) => any
): void;
}S-expression environment includes:
- Fantasy Land combinators:
fmap,chain,filter,compose,pipe - Ramda functions (via @agi/arrival)
- Your registered functions
ActionToolInteraction
Purpose: Batched state mutations with validation
abstract class ActionToolInteraction<TContext> {
// Override these
abstract description: string;
abstract contextSchema: Record<string, z.ZodType>;
// Register actions in constructor
protected registerAction<TProps>({
name: string,
description: string,
props: Record<string, z.ZodType>,
handler: (context: TContext, props: TProps) => any
}): void;
}Action validation:
- All actions validated before executing any
- Fails fast on validation error (nothing executed)
- Returns partial results on runtime error
MCPServerBridge
Purpose: Connect framework to MCP SDK
class MCPServerBridge {
constructor(
serverInfo: { name: string; version: string },
...tools: Constructor<ToolInteraction<any>>[]
);
async connect(transport: Transport): Promise<void>;
}Testing
Run with MCP Inspector
# Install inspector
npm install -g @modelcontextprotocol/inspector
# Build your server
bun run build
# Launch inspector
npx @modelcontextprotocol/inspector node dist/examples/server.jsThis opens a web UI where you can:
- List available tools
- Test tool calls
- See request/response JSON
- Verify schema generation
Manual Testing
# Build
bun run build
# Run server (stdio mode)
node dist/examples/server.js
# Send JSON-RPC over stdin
# (requires manual JSON-RPC formatting)Project Structure
packages/agency/
├── src/
│ ├── framework/
│ │ ├── ToolInteraction.ts # Base class
│ │ ├── DiscoveryToolInteraction.ts # Eyes (S-expressions)
│ │ ├── ActionToolInteraction.ts # Hands (batched actions)
│ │ └── MCPServerBridge.ts # SDK integration
│ ├── examples/
│ │ ├── TodoDiscoveryTool.ts # Example eyes
│ │ ├── TodoActionTool.ts # Example hands
│ │ └── server.ts # Example server
│ └── index.ts # Public exports
├── package.json
├── tsconfig.json
├── README.md # This file
└── ARCHITECTURE.md # Design decisionsPhilosophy
Eyes (Discovery)
What: Pure functional queries over system state
Why:
- Observation shouldn't change what's observed
- Composable via S-expressions
- Safe to retry (no side effects)
- Fantasy Land algebraic structures
How:
- Register functions in LIPS environment
- Functions become S-expression primitives
- Compose using
fmap,chain,filter, etc.
Hands (Action)
What: Batched mutations with validation
Why:
- Explicit intent to modify
- All-or-nothing validation prevents partial states
- Context sharing across batch
- Clear error reporting
How:
- Register actions with Zod schemas
- Actions as tuples:
["action-name", ...args] - Validate all, then execute sequentially
Together
Eyes → Brain → Hands → Eyes → ∞
Observe → Decide → Act → Verify → LearnThis isn't just REPL. This is complete agency.
Advanced Usage
Context-Aware Tools
// Discovery with context
class ProjectDiscoveryTool extends DiscoveryToolInteraction<{
projectId: string;
}> {
contextSchema = {
projectId: z.string()
};
async registerFunctions(context) {
const { projectId } = context;
this.registerFunction("get-tasks", "Get project tasks", [],
async () => await db.tasks.where({ projectId }));
return async () => {};
}
}
// Action with context
class ProjectActionTool extends ActionToolInteraction<{
projectId: string;
}> {
contextSchema = {
projectId: z.string()
} as const;
constructor(context: Context) {
super(context);
this.registerAction({
name: "create-task",
props: {
projectId: z.string(), // From context
title: z.string() // From args
},
handler: async (context, { title }) => {
// context.projectId available here
return await db.tasks.create({
projectId: context.projectId,
title
});
}
});
}
}Compositional Queries
; Get IDs of incomplete todos
(fmap (lambda (t) (get 'id t))
(filter-completed false))
; Count incomplete todos
(length (filter-completed false))
; Chain operations
(chain (lambda (id) (get-by-id id))
(list "1" "2" "3"))
; Compose functions
((compose count-items filter-active) items)Error Handling
Discovery errors:
// Timeout protection (5 seconds)
try {
await tool.executeTool({ expr: "(infinite-loop)" });
} catch (e) {
// Error: "Timeout"
}
// Invalid function
try {
await tool.executeTool({ expr: "(nonexistent)" });
} catch (e) {
// Error: "nonexistent: not defined"
}Action errors:
// Validation error (before execution)
const result = await tool.executeTool({
actions: [["invalid-action", "arg"]]
});
// result.success === false
// result.validation === "failed"
// result.errors === [...]
// Runtime error (during execution)
const result = await tool.executeTool({
actions: [
["create", "1", "text"],
["create", "1", "duplicate"], // fails here
["create", "2", "text"] // never executed
]
});
// result.partial === true
// result.executed === 1
// result.failedAction.error === "..."Dependencies
@modelcontextprotocol/sdk- MCP protocol implementation@agi/arrival- S-expression serialization and LIPS integrationzod- Schema validationhono- Context type (minimal usage)
License
MIT
Troubleshooting
HTTP 524 Timeouts (Cloudflare Tunnel)
If using periphery through Cloudflare tunnel and seeing 524 timeout errors:
Diagnosis: Periphery operations complete quickly (typically <10ms), but responses may not reach the client through tunnel congestion.
Solutions:
- Use
localhost:7777directly when working locally (bypasses tunnel) - Accept occasional tunnel unreliability for remote access
- Consider ngrok/bore as more stable alternatives to Cloudflare free tier
Verify periphery is working:
curl http://localhost:7777/health
# Should return instantly with session infoPeriphery logs show 🔧 discover when operations start. Operations complete fast - if you see cancellations, the issue is network/tunnel, not periphery.
Contributing
See ARCHITECTURE.md for design decisions and extension points.
Eyes to observe. Hands to manipulate. Together: complete agency.
~ ≡ ∞
