Skip to content

Architecture

The platform is organised in layers. Each layer depends only on the ones below it, and the core layer depends on nothing platform-specific. This is what keeps integrations decoupled from one another and from the runtimes that use them.

┌─────────────────────────────────────────────────────────┐
│ Consumers │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Agent │ │ Sandbox │ │ HTTP API │ │
│ │ (LLM tools) │ │ (scripts) │ │ (host) │ │
│ └───────┬───────┘ └───────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └──────────────────┴─────────────────┘ │
│ │ │
│ Actions (typed) │
├─────────────────────────────┼─────────────────────────────┤
│ Providers │ │
│ ┌──────────────────────────▼──────────────────────────┐ │
│ │ ProviderInstance: actions · entities · events │ │
│ │ (adapts one external system into the contracts) │ │
│ └──────────────────────────┬──────────────────────────┘ │
├─────────────────────────────┼─────────────────────────────┤
│ Core contracts ▼ │
│ actions · entities · events · items · registries │
│ Services (DI) · database · buckets · errors · prompts │
└─────────────────────────────────────────────────────────┘

The core layer (@grundlag/core) defines the vocabulary and nothing else. It has no knowledge of any specific integration. It provides:

  • The define* builders — defineAction, defineProviderType, defineEntityType, defineEvent, defineItemType, defineDatabase — each of which pairs a definition with Zod schemas for validation.
  • The registriesActionRegistry, EntityTypeRegistry, EventRegistry, ProviderRegistry, ProviderTypeRegistry, ItemTypeRegistry — behaviour-oriented collections you register into, list, and look up by id.
  • Services, a dependency-injection container for shared capabilities (see Services and dependency injection).
  • Two storage helpers for provider-local state, each declared with defineDatabase / defineBucket and backed by a creator the host supplies: a database built on Kysely, and buckets — an S3-shaped object store for file blobs.

Because the core is only contracts, it is the one dependency every other package shares.

A second contract package, @grundlag/shared, sits alongside it and holds the pieces that must be identical on both sides of an HTTP gap: the neutral Tool interface the sandbox and agent consume, the conversation-item store, and the client/server wire protocol. The core depends on it so a provider action can be bound into a tool; the client depends on it so a remote caller speaks the same protocol. See Tools and transports.

A provider adapts a single external system into the core contracts. It is packaged as a ProviderType — a factory with a config schema and a setup function — that produces a ProviderInstance at runtime. The instance owns four registries. Three of them — actions, events, and entities — are the provider’s public surface: whatever it registers there is everything the platform can see, while its own connection handling, caching, and API calls stay private behind those contracts. The fourth, buckets, is declaration rather than publication: it makes the provider’s blob storage known at boot, and a registered bucket stays private unless it is marked exposed.

Providers depend on the core and nothing else. They never import the server or an agent. This is what lets the same provider run inside an HTTP host, an agent, and a sandbox without change. See Providers and registries.

A host is an application you compose. It creates a Services container, registers the provider types it wants with their configuration, and exposes the result. The @grundlag/server package gives you createServer, a Fastify app that turns every registered provider’s actions and entities into a typed HTTP API, plus an eval endpoint and MCP. You write a small entry file that wires your providers into it — the platform ships the building block, not a fixed deployment. See Compose your own host.

Consumers operate over actions without caring where they came from. A consumer can be anything — an ordinary web application, a background script, an automation, or an LLM-driven agent — because they all speak the same typed contracts. ProviderRegistry flattens every registered provider’s actions into one namespaced map (via toActions()), and each built-in consumer takes it from there:

  • The agent (@grundlag/agent) converts each action’s input schema into an OpenAI tool definition and runs the model’s tool-calling loop. See The agent run loop.
  • The sandbox (@grundlag/sandbox) exposes each action as a typed async function to a script it executes in an isolated QuickJS runtime, recording a call log so a script can be resumed deterministically. See Determinism and resumption.
  • The HTTP API exposes each action as a POST endpoint with request and response validation derived from the schemas.

The agent and the sandbox are not tied to the server process. Both consume a neutral Tool — an action bound into a container-free callable — and a tool can be backed either by a direct in-process call or by an HTTP request to a running host. @grundlag/client provides the second binding: it reads a host’s manifest, generates a client typed against that host’s providers, and reconstructs the host’s actions as HTTP-backed tools. Hand those to a sandbox or agent and the run executes on the client while the capabilities stay behind the host. This is what lets you build the same runner on either side of the network. See Tools and transports.

The thread that ties it together: interrupts

Section titled “The thread that ties it together: interrupts”

One mechanism spans all layers. An action can pause itself by throwing an ActionInterrupt — to ask a human for approval, say. That interrupt propagates up through whichever consumer is running: the sandbox captures it as a resumable checkpoint, and the agent surfaces it as an interrupted run you can later resume or reject. Because the interrupt is defined in the core, every layer understands it. See Interrupts.