# @axiom-lattice/core

Core agent framework providing lattice managers for models, tools, agents, memory, stores, and more.

## Store Configuration

### `configureStores`

Unified store registration that replaces the manual `new → initialize → remove → register` boilerplate.
Handles `StoreLatticeManager`, `ScheduleLatticeManager`, and `MemoryLatticeManager` through a single call.

```typescript
import { configureStores } from "@axiom-lattice/core";
import { createPgStoreConfig } from "@axiom-lattice/pg-stores";
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";

// One connection string creates all PG store instances
const stores = createPgStoreConfig(process.env.DATABASE_URL);

// One call registers all stores across three managers
await configureStores({
  ...stores,
  checkpoint: PostgresSaver.fromConnString(process.env.DATABASE_URL),
}, { autoDisposeStores: true });
```

### Supported store keys

| Key | Registered via |
|-----|---------------|
| `thread`, `assistant`, `workspace`, `project`, `database`, `metrics`, `mcp`, `user`, `tenant`, `userTenantLink`, `threadMessageQueue`, `workflowTracking`, `eval`, `channelInstallation`, `channelBinding`, `skill` | `StoreLatticeManager` |
| `schedule` | `ScheduleLatticeManager` (auto-configured as POSTGRES) |
| `checkpoint` | `MemoryLatticeManager` (accepts any CheckpointSaver) |

### Behavior

For each store entry:

1. Calls `store.initialize()` if the method exists (skipped if it requires arguments)
2. Removes any existing `"default"` registration
3. Registers the new store under `"default"`
4. Tracks `store.dispose` for cleanup

### Options

```typescript
interface ConfigureStoresOptions {
  autoDisposeStores?: boolean;     // Register SIGINT/SIGTERM handlers for cleanup
  customStores?: Record<string, object>;  // Register custom store types
}
```

Returns a `dispose` function for manual cleanup regardless of `autoDisposeStores`.

### Custom stores

Register custom store implementations that follow the same `initialize()` / `dispose()` pattern:

```ts
interface MyCustomStore {
  getData(): Promise<string>;
}

class PostgreSQLMyCustomStore implements MyCustomStore {
  async initialize() { /* run migrations */ }
  async dispose() { /* close pool */ }
  async getData() { return "from pg"; }
}
```

```ts
await configureStores({
  thread: new PostgreSQLThreadStore(opts),
}, {
  customStores: {
    myCustom: new PostgreSQLMyCustomStore({ poolConfig }),
  },
});

// Consume via StoreLatticeManager
const { store } = getStoreLattice("default", "myCustom");
const customStore = store as MyCustomStore;
```

### Consumer pattern

Components that need stores read them directly from `StoreLatticeManager` — no `setConfigStore` required:

```typescript
// SqlDatabaseManager reads database configs lazily from the store lattice
const db = await sqlDatabaseManager.getDatabase(tenantId, "main-db");

// MetricsServerManager loads configs on first access
const client = await metricsServerManager.getClient(tenantId, "prometheus");
const servers = await metricsServerManager.getServerKeys(tenantId);
```

---

## Other Lattice Managers

### Model Lattice

```typescript
import { registerModelLattice } from "@axiom-lattice/core";

registerModelLattice("default", {
  model: "gpt-4o",
  provider: "openai",
  streaming: true,
  apiKeyEnvName: "OPENAI_API_KEY",
});
```

### ChunkBuffer

```typescript
import { InMemoryChunkBuffer, registerChunkBuffer } from "@axiom-lattice/core";

const buffer = new InMemoryChunkBuffer({ ttl: 30 * 60 * 1000 });
registerChunkBuffer("default", buffer);
```

## Directory Structure

- `src/store_lattice/` — Store lattice manager + configureStores
- `src/model_lattice/` — LLM provider abstractions
- `src/tool_lattice/` — Tool implementations (SQL, metrics, etc.)
- `src/agent_lattice/` — Agent definitions and builders
- `src/memory_lattice/` — Context/memory management
- `src/schedule_lattice/` — Scheduled task management
- `src/sandbox_lattice/` — Code execution sandbox providers

---

## Custom Middleware Registry

Applications can write their own middleware and register it at runtime without modifying core source code. The registered middleware can then be enabled via agent config stored in the database.

### Quick start

```typescript
import { createMiddleware, CustomMiddlewareRegistry } from "@axiom-lattice/core";

CustomMiddlewareRegistry.register("my-logger", (config) =>
  createMiddleware({
    name: "MyLogger",
    wrapModelCall: async (request, handler) => {
      console.log(`[${config.logLevel}] Model call`);
      return handler(request);
    },
  })
);
```

### Database config format

In your agent's `graphDefinition.middleware` array, add a `"custom"` type entry:

```json
{
  "id": "mw-001",
  "type": "custom",
  "name": "Audit Logger",
  "description": "Logs model calls for audit",
  "enabled": true,
  "config": {
    "key": "my-logger",
    "logLevel": "debug"
  }
}
```

The `config.key` must match the key passed to `register()`. All other config fields are forwarded to your factory function.

### Full example — permission-check middleware

```typescript
import { createMiddleware, CustomMiddlewareRegistry } from "@axiom-lattice/core";

CustomMiddlewareRegistry.register("permission-check", (config) =>
  createMiddleware({
    name: "PermissionCheck",
    wrapToolCall: async (request, handler) => {
      const toolName = request.toolCall?.name;
      if (toolName && !config.allowedTools.includes(toolName)) {
        return { content: `Tool "${toolName}" is not permitted.` };
      }
      return handler(request);
    },
  })
);
```

Database config:

```json
{
  "id": "perm-001",
  "type": "custom",
  "name": "Tool Permissions",
  "description": "Restrict tool access",
  "enabled": true,
  "config": {
    "key": "permission-check",
    "allowedTools": ["read_file", "write_file"]
  }
}
```

### API reference

| Method | Signature | Description |
|--------|-----------|-------------|
| `register` | `(key: string, factory: (config: Record<string, any>) => AgentMiddleware \| Promise<AgentMiddleware>) => void` | Register a factory by key |
| `unregister` | `(key: string) => boolean` | Remove a registration |
| `get` | `(key: string) => AgentMiddlewareFactory \| undefined` | Look up a factory |
| `has` | `(key: string) => boolean` | Check if key is registered |
| `list` | `() => string[]` | Get all registered keys |

### Important

- Register factories **before** building agents (typically at app startup)
- Duplicate keys overwrite previous registrations
- Unregistered keys are skipped with a console warning at build time
- Factory functions receive the raw `config` object (minus `key`) — validate it yourself

---

## External Channel Integration

core provides the storage infrastructure and binding system that external channel adapters (Lark, Slack, Email, etc.) depend on. The actual HTTP routes and message dispatch live in `packages/gateway`, but all persistence and sender resolution are handled here.

### What core provides

| Component | Location | Purpose |
|-----------|----------|---------|
| `InMemoryChannelInstallationStore` | `store_lattice/` | In-memory `ChannelInstallationStore` implementation |
| `InMemoryBindingStore` | `store_lattice/` | In-memory `BindingRegistry` implementation |
| `BindingRegistryHolder` | `bindings_lattice/` | Global accessor for the active `BindingRegistry` |
| `manage_binding` tool | `tool_lattice/manage_binding/` | Agent tool for CRUD on sender-to-agent bindings |

### Architecture overview

```
External Platform (Lark, Slack, Email)
  → Gateway HTTP route (packages/gateway)
  → ChannelAdapter.receive(rawPayload) → InboundMessage
  → MessageRouter.dispatch() (packages/gateway)
    → BindingRegistry.resolve() ←── core
    → Thread creation / reuse ←── core
    → Agent.addMessage() ←── core
  → ChannelAdapter.sendReply() (packages/gateway)
```

Agents remain **channel-agnostic** — they only see standard thread/message execution.

### Setup for custom channel development

#### 1. Register channel stores

```typescript
import { configureStores } from "@axiom-lattice/core";
import { PostgreSQLChannelInstallationStore, PostgreSQLBindingStore } from "@axiom-lattice/pg-stores";

await configureStores({
  channelInstallation: new PostgreSQLChannelInstallationStore({ pool }),
  channelBinding: new PostgreSQLBindingStore({ pool }),
  // ... other stores
});
```

#### 2. Set the global BindingRegistry

```typescript
import { setBindingRegistry } from "@axiom-lattice/core";

const bindingStore = getStoreLattice("default", "channelBinding").store;
setBindingRegistry(bindingStore);
```

#### 3. Implement ChannelAdapter (in gateway)

```typescript
import type { ChannelAdapter, InboundMessage, OutboundMessage, ReplyTarget } from "@axiom-lattice/protocols";
import { z } from "zod";

const slackConfigSchema = z.object({
  botToken: z.string(),
  signingSecret: z.string(),
});

export const slackAdapter: ChannelAdapter = {
  channel: "slack",
  configSchema: slackConfigSchema,

  async receive(rawPayload, installation): Promise<InboundMessage | null> {
    // 1. Parse Slack-specific event
    // 2. Return normalized InboundMessage or null to ignore
    return {
      channel: "slack",
      channelInstallationId: installation.id,
      tenantId: installation.tenantId,
      sender: { id: userId, displayName: userName },
      content: { text: messageText },
      replyTarget: {
        adapterChannel: "slack",
        channelInstallationId: installation.id,
        rawTarget: { channelId, threadTs },
      },
    };
  },

  async sendReply(replyTarget, message, installation): Promise<void> {
    // Use Slack API to send reply
    // replyTarget.rawTarget contains channel-specific context
  },
};
```

#### 4. Register routes and adapter (in gateway)

```typescript
// packages/gateway/src/channels/registry.ts
adapterRegistry.register(slackAdapter);

// packages/gateway/src/channels/routes.ts
app.post("/api/channels/slack/installations/:id/events", async (req, reply) => {
  const installation = await installationStore.getInstallationById(req.params.id);
  const message = await slackAdapter.receive(req.body, installation);
  if (message) await messageRouter.dispatch(message);
});
```

### Binding resolution flow

When `MessageRouter.dispatch()` receives an `InboundMessage`:

1. Calls `BindingRegistry.resolve({ channel, senderId, channelInstallationId, tenantId })`
2. If **no binding found**:
   - If `installation.rejectWhenNoBinding` → throw `BindingNotFoundError`
   - If `installation.fallbackAgentId` → create temporary fallback binding
3. If **binding disabled** → reject
4. **Thread resolution** (based on `binding.threadMode`):
   - `"fixed"` → reuse `binding.threadId`
   - `"per_conversation"` → always create new thread
5. Execute agent via `agent.addMessage()`

### Agent-side binding management

Agents can manage bindings dynamically via the `manage_binding` tool:

```typescript
// Agent can call this tool to:
// - list_installations: List available channel installations
// - create: Bind a sender to an agent (channel, senderId, agentId)
// - update: Change agent or threadMode for a binding
// - delete: Remove a binding
// - list: List all bindings with optional filters
```

### Key types (from @axiom-lattice/protocols)

```typescript
interface ChannelAdapter<TConfig = unknown> {
  readonly channel: string;
  readonly configSchema: z.ZodSchema<TConfig>;
  receive(rawPayload: unknown, installation: ChannelInstallation): Promise<InboundMessage | null>;
  sendReply(replyTarget: ReplyTarget, message: OutboundMessage, installation: ChannelInstallation): Promise<void>;
}

interface Binding {
  id: string;
  channel: string;
  channelInstallationId: string;
  tenantId: string;
  senderId: string;
  agentId: string;
  threadMode: "fixed" | "per_conversation";
  enabled: boolean;
}
```

### Reference implementation

See `packages/gateway/src/channels/lark/` for a complete production channel adapter including:
- Webhook verification and decryption
- Event parsing and normalization
- Thread mapping (`user` / `group` / `hybrid` modes)
- Reply delivery via official SDK
