Skip to content
/ annette Public

A TypeScript library for crafting interaction nets

License

Notifications You must be signed in to change notification settings

doeixd/annette

Repository files navigation

Annette

A TypeScript library for crafting interaction nets, with abstractions for reactive programming, state management, distributed systems, and more.

npm version Ask DeepWiki

📖 API Documentation | 📚 Examples | 📦 npm package

What is Annette?

Annette is a TypeScript library that models applications as networks of interacting agents. Instead of traditional object-oriented or functional approaches, it uses a graph-based model where:

  • Agents are like tiny programs with specific roles
  • Ports are connection points on agents
  • Rules define what happens when agents connect
  • Networks manage the connections and execution

This approach can help with:

  • Understanding data flow in complex applications
  • Building reactive user interfaces
  • Managing state across distributed systems
  • Debugging with time travel capabilities

Installation

npm install annette

Table of Contents

Quick Start

Let's start with something simple - a counter that increments when clicked:

import { Agent, Network, ActionRule } from 'annette';

// 1. Create a network (like a container for your agents)
const net = Network("counter-app");

// 2. Create agents (like tiny programs with specific roles)
const counter = Agent("Counter", 0);
const incrementer = Agent("Incrementer", 1);

// 3. Add agents to the network
net.addAgent(counter);
net.addAgent(incrementer);

// 4. Define what happens when agents connect
const incrementRule = ActionRule(
  counter.ports.main,
  incrementer.ports.main,
  (counter, incrementer) => {
    // This runs when the agents connect
    counter.value += incrementer.value;
    return [counter, incrementer]; // Return agents to keep them in the network
  }
);

// 5. Add the rule to the network
net.addRule(incrementRule);

// 6. Connect the agents
net.connectPorts(counter.ports.main, incrementer.ports.main);

// 7. Execute the interaction
console.log("Before:", counter.value); // 0
net.step(); // Execute one interaction
console.log("After:", counter.value);  // 1

That's it! You've just created your first Annette application. The counter and incrementer are agents that interact through a rule when connected.

Scoped Network API (New)

The scoped API creates a bound set of helpers for a single network instance:

import { createNetwork } from 'annette';

const { Agent, withConnections, scope } = createNetwork('app');

const Counter = withConnections(Agent.factory<'Counter', number>('Counter'), {
  add: (counter) => {
    counter.value += 1;
  }
}, { autoDisconnectMain: true });

scope.reduce(() => {
  const counter = Counter(0);
  counter.add();
  counter.add();
  console.log(counter.value); // 2
});

Scope helpers:

  • scope.step() queues work inside the callback, then runs one step.
  • scope.reduce() queues work inside the callback, then reduces until quiescent.
  • scope.manual() runs without auto-stepping and returns your callback value.

The scoped helpers include factory-based Agent, Port, Rule, and Connection utilities so you can stay inside the same network context. Use autoDisconnectMain if you plan to queue multiple method calls before stepping.

batch() groups method calls and runs a single step (or batch.reduce() to exhaust all pending work). untrack() runs a callback without tracking, so no agents, connections, or rules are registered.

const { batch, untrack } = createNetwork('app');

batch(() => {
  counter.add();
  counter.add();
});

batch.reduce(() => {
  counter.add();
  counter.add();
});

untrack(() => {
  Counter(1); // not added to the network
});

Operational notes:

  • rules.list() includes symmetric rules by default. Pass { includeSymmetric: false } to hide mirrored entries.
  • asNestedNetwork() wraps a scoped network when embedding it inside another agent value.

Rule DSL (New)

import { createNetwork, consume } from 'annette';

const { Agent, rules, connect, step, fnAgent } = createNetwork('app');

const Counter = Agent.factory<'Counter', number>('Counter');
const Incrementer = Agent.factory<'Incrementer', number>('Incrementer');

rules.when(Counter, Incrementer)
  .consume((counter, incrementer) => {
    counter.value += incrementer.value;
  });

const ruleList = rules.list({ includeSymmetric: false });

const FnIncrementer = fnAgent(Counter, 'FnIncrementer', consume((counter, incrementer) => {
  counter.value += incrementer.value;
}));

const counter = Counter(1);
const incrementer = Incrementer(2);

connect(counter, incrementer);
step();

Fluent rule chains let you refine matching and outcomes:

  • rules.when() returns a builder with .on(), .where(), .consume(), .spawn(), .transform(), and .mutate().
  • Wrapper helpers like consume() and spawn() adapt handlers for fnAgent() or withConnections().

You can also attach pair interactions directly on an agent factory:

import { pair } from 'annette';

withConnections(Counter, {
  applyIncrement: pair(Incrementer, (counter, incrementer) => {
    counter.value += incrementer.value;
  })
});

Graph Mounting (DOM)

renderToGraph builds a DOM-backed agent graph from a template tree. Each element becomes an Element agent, text becomes a TextNode, and attributes become Attribute agents connected to the element.

import { Network, renderToGraph } from 'annette';

const network = Network('dom');
const template = {
  tag: 'div',
  attrs: { class: 'card' },
  children: [
    { tag: 'h1', children: [{ tag: 'text', text: 'Hello Graph' }] },
    { tag: 'p', children: [{ tag: 'text', text: 'Mounted with renderToGraph.' }] }
  ]
};

const root = renderToGraph(network, template);

if (root.name === 'Element' && root.value.ref) {
  document.body.appendChild(root.value.ref);
}

Notes:

  • Requires a browser document for DOM creation.
  • Attribute agents are created and connected; syncing them to actual DOM attributes is left to rules or effects.

Optimized DOM Blocks

createOptimizedDomSystem provides Million-style block updates, map-style list diffing, and Solid-like selection. Blocks hold a map of edit nodes, and updates are applied in O(1) via Update agents.

import { createNetwork, createOptimizedDomSystem } from 'annette';

const scope = createNetwork('dom');
const dom = createOptimizedDomSystem(scope);

const Row = dom.createBlockTemplate<{ label: string }>(
  `<div class="row"><span class="label"></span></div>`,
  (root) => ({ label: root.querySelector('.label') as HTMLElement })
);

const selectionManager = dom.SelectionManager({ selectedClass: 'selected' });

const listManager = dom.createListManager({
  container: document.body,
  blockFactory: Row,
  activeBlocks: new Map(),
  selectionManager,
  getUpdates: (item) => [{ key: 'label', value: item.data.label, type: 'text' }]
});

dom.updateList(listManager, [
  { id: '1', data: { label: 'Buy Milk' } },
  { id: '2', data: { label: 'Walk Dog' } }
]);

Notes:

  • Blocks are agents with an edits map used for O(1) patching.
  • selectBlock connects the block to the selection manager’s active port.

Zero-Cost Topology

The zero module exposes a functional, zero-cost topology runtime where ports are direct function references. It keeps type inference by modeling ports as typed callables.

import { zero } from 'annette';

const zeroNetwork = zero.createNetwork();

const Counter = zeroNetwork.Agent('Counter', (initial: number) => {
  let value = initial;
  const main = zeroNetwork.createPort<number>((delta) => {
    value += delta;
  });
  return { main };
});

zeroNetwork.run(() => {
  const counter = Counter(0);
  const increment = zeroNetwork.createPort<number>((amount) => {
    counter.main(amount);
  });

  zeroNetwork.connect(counter.main, increment);

  increment(5);
});

Notes:

  • zeroNetwork.run() scopes hierarchy and cleanup tracking.
  • zeroNetwork.connect rewires ports by swapping function pointers.
  • There is no scheduler; ports execute immediately on call.
  • Use zero.middleware helpers for recording, sync, and debugging.
  • Use zero.createFanout/zero.createRouter for high-performance fanout and rewiring.

Topology State Machines

Topology-based state machines express state as a connected agent rather than a string. The createTopologyStateMachine helper wires ActionRules that replace the active state agent while keeping the machine connection intact.

import { createNetwork, createTopologyStateMachine } from 'annette';

const scope = createNetwork('machine');
const { Agent, Port, connect } = scope;

const Machine = Agent.factory<{ name: string }>('Machine', {
  ports: { main: Port.main(), aux: Port.aux('aux') }
});

const Idle = Agent.factory<null>('Idle', {
  ports: { main: Port.main(), aux: Port.aux('aux') }
});

const Working = Agent.factory<{ attempt: number }>('Working', {
  ports: { main: Port.main(), aux: Port.aux('aux') }
});

const Start = Agent.factory<null>('Start');

const topology = createTopologyStateMachine(scope, {
  machinePort: 'aux',
  statePort: 'aux',
  stateEventPort: 'main',
  eventPort: 'main'
});

topology.transition(Idle, Start, Working, {
  mapValue: () => ({ attempt: 1 })
});

const machine = Machine({ name: 'Process' });
const idle = Idle(null);

connect(machine.ports.aux, idle.ports.aux);

topology.dispatch(machine, Start(null));
console.log(topology.getState(machine)?.name); // Working

Notes:

  • The machine holds the active state via a dedicated port (default aux).
  • Events are temporary agents removed by the transition rule.

Observer Event System

createEventSystem models events as a linked list of listener agents. Emitting an event spawns a pulse agent that traverses the chain and triggers callbacks.

import { createNetwork, createEventSystem } from 'annette';

const scope = createNetwork('events');
const events = createEventSystem(scope);

const onLogin = events.createEvent<{ username: string }>('UserLogin');

events.listen(onLogin, (data) => {
  console.log(`Email welcome: ${data.username}`);
});

events.listen(onLogin, (data) => {
  console.log(`Analytics login: ${data.username}`);
});

events.emit(onLogin, { username: 'alice' });

Notes:

  • Listener insertion happens at the head of the chain.
  • Pulses are removed after reaching the terminator.

Reified Effects

createReifiedEffectSystem models async work as agents. An Effect connects to a Handler, which runs the async function and later injects a Result or ErrorResult back to the requester.

import { createNetwork, createReifiedEffectSystem } from 'annette';

const scope = createNetwork('effects');
const { Agent, Port, connect, rules } = scope;

const effects = createReifiedEffectSystem(scope);

const Client = Agent.factory<{ data: string | null }>('Client', {
  ports: { main: Port.main('main'), io: Port.aux('io') }
});

rules.when(Client, effects.Result).consume((client, result) => {
  const value = result.value as { data: string };
  client.value.data = value.data;
});

const handler = effects.Handler({
  topic: 'FETCH',
  fn: async () => 'ok'
});

const client = Client({ data: null });
connect(client.ports.io, handler.ports.capability);

effects.requestFrom(client.ports.io, client.ports.main, 'FETCH', { url: '/api' });

Notes:

  • request removes the effect after it reaches the handler.
  • Results are injected asynchronously via scoped.step().

Storylines

Storylines let you record method calls and replay or serialize them later:

import { createNetwork } from 'annette';

const { Agent, withConnections, storyline } = createNetwork('app');

const Counter = withConnections(Agent.factory<number>('Counter'), {
  add: (counter) => {
    counter.value += 1;
  }
}, { autoDisconnectMain: true });

const story = storyline(Counter, function* (counter) {
  yield* counter.add();
  yield* counter.add();
  return counter;
});

const counter = Counter(0);
story.apply(counter);

const serialized = story.serialize({ format: 'json' });
const replay = story.deserialize(serialized, { format: 'json' });
replay.apply(counter);

Nested Networks

Agents can hold scoped networks as values and expose explicit stepping rules:

import { createNetwork, asNestedNetwork } from 'annette';

const child = createNetwork('child');
const { Agent: ChildAgent, withConnections: childConnections } = child;

const Counter = childConnections(ChildAgent.factory<'Counter', number>('Counter'), {
  add: (counter) => {
    counter.value += 1;
  }
});

const parent = createNetwork('parent');
const { Agent, withConnections, scope } = parent;

type ChildNetwork = typeof child;
const nested = asNestedNetwork(child);

const Host = withConnections(Agent.factory<'Host', ChildNetwork>('Host'), {
  stepInner: (host) => {
    nested.step();
  },
  reduceInner: (host) => {
    nested.reduce();
  }
}, { autoDisconnectMain: true });

scope.reduce(() => {
  const host = Host(child);
  const counter = Counter(0);
  counter.add();
  host.stepInner();
});

Core Concepts

Agents

Think of agents as tiny programs with a specific role. Each agent has:

  • A name (like "Counter", "User", "Database") that defines its type
  • A value that can be any TypeScript type
  • Named ports for connecting to other agents
// Basic agent with a number value
const counter = Agent<"Counter", number>("Counter", 0);

// Agent with complex data
const user = Agent<"User", { name: string; age: number }>("User", {
  name: "Alice",
  age: 30
});

// Agent with custom ports
const processor = Agent("Processor", { status: "idle" }, {
  input: Port("input", "main"),
  output: Port("output", "aux"),
  control: Port("control", "aux")
});

// Factory from existing agent
const ProcessorFactory = Agent.factoryFrom(processor);
const cloned = ProcessorFactory({ status: "ready" });

Agent.factory() infers the name from the string literal, so you can provide only the value type:

const Counter = Agent.factory<number>('Counter');

Key Insight: Agents are like actors in a play - they have a role (name), state (value), and ways to communicate (ports).

Ports

Ports are connection points on agents. They're like electrical sockets - you can plug things into them.

// Create different types of ports
const mainPort = Port("main", "main");    // Main interaction point
const auxPort = Port("output", "aux");    // Auxiliary connection point

// Convenience helpers
const mainShortcut = Port.main();
const auxShortcut = Port.aux("output");

// Factory from existing port
const PortCopy = Port.factoryFrom(auxPort);
const cloned = PortCopy();

Each port has:

  • A name (like "main", "input", "output")
  • A type ("main" or "aux")

Key Insight: Main ports are for primary interactions, aux ports are for secondary connections (like passing results to other agents).

Networks

Networks are containers that hold agents and manage their interactions.

// Create a network
const net = Network("my-app");

// Add agents
net.addAgent(counter);
net.addAgent(incrementer);

// Connect agents
net.connectPorts(counter.ports.main, incrementer.ports.main);

// Execute interactions
net.step();    // One interaction
net.reduce();  // All possible interactions

Key Insight: The network is like a circuit board - it provides the structure for agents to interact.

Rules

Rules define what happens when agents connect. There are two types:

ActionRule (Imperative)

For custom logic when agents interact:

const incrementRule = ActionRule(
  counter.ports.main,
  incrementer.ports.main,
  (counter, incrementer) => {
    // Your custom logic here
    counter.value += incrementer.value;
    return [counter, incrementer]; // Return agents to keep them
  }
);

RewriteRule (Declarative)

For transforming the network structure:

const addRule = RewriteRule(
  number1.ports.main,
  number2.ports.main,
  (n1, n2) => ({
    newAgents: [
      {
        name: "Result",
        initialValue: n1.value + n2.value,
        _templateId: "sum"
      }
    ],
    internalConnections: [],
    portMapAgent1: { result: { newAgentTemplateId: "sum", newPortName: "main" } },
    portMapAgent2: { result: null }
  })
);

Key Insight: ActionRules are for "what to do", RewriteRules are for "how to transform the graph".

Connections

Connections link ports between agents:

// Connect two ports
net.connectPorts(agent1.ports.main, agent2.ports.input);

// Or create a connection object first
const conn = Connection(agent1.ports.output, agent2.ports.input);
net.addConnection(conn);

Key Insight: Connections are like wires in a circuit - they define the flow of information.

Building Intuition

Let's build your understanding step by step with practical examples.

Simple Counter

Let's create a counter that can be incremented and decremented:

import { Agent, Network, ActionRule } from 'annette';

const net = Network("counter-example");

// Create our agents
const counter = Agent<"Counter", number>("Counter", 0);
const incrementer = Agent<"Incrementer", number>("Incrementer", 1);
const decrementer = Agent<"Decrementer", number>("Decrementer", -1);

// Add them to the network
net.addAgent(counter);
net.addAgent(incrementer);
net.addAgent(decrementer);

// Create rules for incrementing and decrementing
const incrementRule = ActionRule(
  counter.ports.main,
  incrementer.ports.main,
  (counter, incrementer) => {
    counter.value += incrementer.value;
    return [counter, incrementer];
  }
);

const decrementRule = ActionRule(
  counter.ports.main,
  decrementer.ports.main,
  (counter, decrementer) => {
    counter.value += decrementer.value; // Adding a negative number
    return [counter, decrementer];
  }
);

// Add rules to network
net.addRule(incrementRule);
net.addRule(decrementRule);

// Test incrementing
console.log("Initial:", counter.value); // 0
net.connectPorts(counter.ports.main, incrementer.ports.main);
net.step();
console.log("After increment:", counter.value); // 1

// Test decrementing
net.connectPorts(counter.ports.main, decrementer.ports.main);
net.step();
console.log("After decrement:", counter.value); // 0

What you learned:

  • Multiple agents can interact with the same counter
  • Each rule defines a specific interaction pattern
  • Agents maintain their state between interactions

State Machine

Let's build a simple state machine:

import { Agent, Network, ActionRule } from 'annette';

const net = Network('state-machine');

// Create state and event agents
const state = Agent<'State', string>('State', 'idle');
const startEvent = Agent<'Event', string>('Event', 'start');
const stopEvent = Agent<'Event', string>('Event', 'stop');

// Add to network
net.addAgent(state);
net.addAgent(startEvent);
net.addAgent(stopEvent);

// Create transition rules
const startRule = ActionRule(
  state.ports.main,
  startEvent.ports.main,
  (state, event) => {
    if (state.value === 'idle' && event.value === 'start') {
      state.value = 'running';
    }
    return [state, event];
  }
);

const stopRule = ActionRule(
  state.ports.main,
  stopEvent.ports.main,
  (state, event) => {
    if (state.value === 'running' && event.value === 'stop') {
      state.value = 'idle';
    }
    return [state, event];
  }
);

net.addRule(startRule);
net.addRule(stopRule);

// Test the state machine
console.log("Initial state:", state.value); // 'idle'

net.connectPorts(state.ports.main, startEvent.ports.main);
net.step();
console.log("After start:", state.value); // 'running'

net.connectPorts(state.ports.main, stopEvent.ports.main);
net.step();
console.log("After stop:", state.value); // 'idle'

What you learned:

  • Agents can represent both data (state) and actions (events)
  • Rules can have conditions (if statements)
  • The same agents can be reused for different interactions

Data Flow

Let's create a data processing pipeline:

import { Agent, Network, ActionRule, Port } from 'annette';

const net = Network("data-pipeline");

// Create data processing agents
const input = Agent("Input", { data: [1, 2, 3, 4, 5] }, {
  main: Port("main", "main"),
  output: Port("output", "aux")
});

const filter = Agent("Filter", { condition: (x: number) => x > 3 }, {
  input: Port("input", "main"),
  output: Port("output", "aux")
});

const output = Agent("Output", { results: [] }, {
  input: Port("input", "main")
});

// Add to network
net.addAgent(input);
net.addAgent(filter);
net.addAgent(output);

// Create processing rules
const inputToFilterRule = ActionRule(
  input.ports.main,
  filter.ports.input,
  (input, filter) => {
    // Process data and pass results to output port
    const filtered = input.value.data.filter(filter.value.condition);
    input.value.processed = filtered;
    return [input, filter];
  }
);

const filterToOutputRule = ActionRule(
  filter.ports.output,
  output.ports.input,
  (filter, output) => {
    // This would receive the processed data
    // In a real scenario, you'd pass data through aux ports
    return [filter, output];
  }
);

net.addRule(inputToFilterRule);
net.addRule(filterToOutputRule);

// Connect the pipeline
net.connectPorts(input.ports.main, filter.ports.input);
net.connectPorts(filter.ports.output, output.ports.input);

// Process the data
net.step();
console.log("Filtered data:", input.value.processed); // [4, 5]

What you learned:

  • Agents can have multiple ports for different types of connections
  • Data can flow through a pipeline of transformations
  • Aux ports can be used for passing results to other agents

Design Philosophy

Annette is based on interaction nets, a theoretical model of computation that naturally embodies several powerful properties:

Natural Causality Preservation

The graph structure inherently maintains cause-and-effect relationships. When agents interact, the connections between them explicitly represent data dependencies:

// Each interaction preserves causality through explicit connections
const incrementRule = ActionRule(
  counter.ports.main,
  incrementer.ports.main,
  (counter, incrementer) => {
    // The counter's new state is directly caused by the incrementer
    counter.value += incrementer.value;
    return [counter, incrementer];
  }
);

Why this matters: Unlike traditional approaches where causality can be implicit and hard to trace, Annette makes data flow explicit through the network structure. This makes debugging, testing, and reasoning about your application much easier.

Built-in Scope Isolation

Each agent maintains its own scope and state boundaries. Agents can only communicate through explicit port connections, preventing unintended side effects:

// Agents maintain clean boundaries
const user = Agent("User", { name: "Alice", preferences: { theme: "dark" } });
const settings = Agent("Settings", { theme: "light" });

// They can only interact through explicit rules
const updateThemeRule = ActionRule(
  user.ports.main,
  settings.ports.main,
  (user, settings) => {
    // Controlled interaction - no accidental state leakage
    user.value.preferences.theme = settings.value.theme;
    return [user, settings];
  }
);

Why this matters: This prevents the "spooky action at a distance" problem common in traditional state management, where changing one piece of state unexpectedly affects others.

Perfect for Distributed Systems

The agent-based model naturally supports distribution because agents are self-contained units that communicate through well-defined interfaces:

// Agents can be distributed across different processes/machines
const clientDoc = Agent("Document", { content: "Hello" });
const serverDoc = Agent("Document", { content: "" });

// Synchronization happens through explicit operations
const syncRule = ActionRule(
  clientDoc.ports.sync,
  serverDoc.ports.sync,
  (client, server) => {
    // Merge changes through defined conflict resolution
    server.value.content = resolveConflicts(client.value.content, server.value.content);
    return [client, server];
  }
);

Why this matters: Traditional approaches often retrofit distribution onto centralized models, leading to complex synchronization issues. Annette's model is inherently distributed-friendly.

Promotes Safe Concurrency

The interaction model naturally supports concurrent execution because rules only affect the agents they explicitly reference:

// Multiple independent interactions can run concurrently
const rule1 = ActionRule(counter1.ports.main, incrementer1.ports.main, /*...*/);
const rule2 = ActionRule(counter2.ports.main, incrementer2.ports.main, /*...*/);

// These can run in parallel since they don't interfere with each other
network.step(); // May execute both rules concurrently if possible

Why this matters: Unlike traditional approaches where shared mutable state requires complex locking mechanisms, Annette's model makes it easier to reason about and implement concurrent operations safely.

These Properties Are Built-In

Unlike traditional libraries where these properties are add-ons or afterthoughts, they're fundamental to Annette's design:

  • No additional libraries needed for causality tracking or time travel
  • No complex setup for distributed synchronization
  • No external tools required for debugging data flow
  • No special configuration for concurrent execution

The interaction nets foundation means these capabilities are naturally present in the model, not bolted on as features.

Advanced Features

Time Travel

Annette can track all changes and let you go back in time:

import { TimeTravelNetwork, Agent, ActionRule } from 'annette';

const net = TimeTravelNetwork("time-travel-counter");

const counter = Agent("Counter", { count: 0 });
const incrementer = Agent("Incrementer", { by: 1 });

net.addAgent(counter);
net.addAgent(incrementer);

net.addRule(ActionRule(
  counter.ports.main,
  incrementer.ports.main,
  (counter, incrementer) => {
    counter.value.count += incrementer.value.by;
    return [counter, incrementer];
  }
));

// Take a snapshot
const snapshot1 = net.takeSnapshot("Initial state");

console.log("Initial:", counter.value.count); // 0

// Make some changes
net.connectPorts(counter.ports.main, incrementer.ports.main);
net.step();
console.log("After increment 1:", counter.value.count); // 1

net.step();
console.log("After increment 2:", counter.value.count); // 2

const snapshot2 = net.takeSnapshot("After two increments");

// Go back in time
net.rollbackTo(snapshot1.id);
console.log("After rollback:", counter.value.count); // 0

Reactivity

Annette has built-in reactive programming:

import { createReactive, createComputed, createEffect } from 'annette';

// Create reactive values
const count = createReactive(0);
const multiplier = createReactive(2);

// Create computed values (automatically update when dependencies change)
const doubled = createComputed(() => count() * multiplier());

// Create effects (run when dependencies change)
createEffect(() => {
  console.log(`Count: ${count()}, Doubled: ${doubled()}`);
});

// Update values - effects run automatically
count(1);        // Logs: "Count: 1, Doubled: 2"
multiplier(3);   // Logs: "Count: 1, Doubled: 3"
count(2);        // Logs: "Count: 2, Doubled: 6"

Distributed Systems

Share state across multiple clients/servers:

import { SyncNetwork, Agent, registerSyncRules } from 'annette';

// Create a sync network
const clientNet = SyncNetwork("client-app", "client-1");
const serverNet = SyncNetwork("server-app", "server-1");

// Register sync rules
registerSyncRules(clientNet);
registerSyncRules(serverNet);

// Create shared document
const clientDoc = Agent("Document", {
  id: "doc-123",
  content: "Hello from client",
  version: 1
});

// Add to client network
clientNet.addAgent(clientDoc);

// In real usage, operations would be sent over network
// This is simplified for the example
const operations = clientNet.collectOperations(0);
serverNet.applyOperations(operations);

Serialization

Convert Annette structures to/from strings for storage or network transfer:

import { serializeValue, deserializeValue } from 'annette';

// Complex object with circular references
const user = {
  name: "Alice",
  profile: { theme: "dark" }
};
user.self = user; // Circular reference

// Serialize (handles circular references automatically)
const serialized = serializeValue(user);

// Deserialize
const deserialized = deserializeValue(serialized);
console.log(deserialized.self === deserialized); // true

API Reference

For the full API (types, examples, and module breakdowns), see the standalone docs:

Quick pointers:

  • Core primitives: Agent, Network, ActionRule, RewriteRule, Connection
  • Scoped helpers: createNetwork, scope, batch, untrack, storyline
  • Reactive APIs: createReactive, createComputed, createSignal, createEffect
  • Serialization: serializeValue, deserializeValue, deepClone

The Network is a CRDT

Annette's interaction nets have confluence — the same result regardless of operation order. This means:

  • No conflict resolution code needed
  • Distributed sync "just works"
  • Time travel is free (replay the interaction log)

You don't build CRDTs on Annette. The network already is one.

Examples

Check out the examples directory for more comprehensive examples:

Tutorials

Learn Annette step by step with our comprehensive tutorials:

When to Use Annette

Annette can be helpful for:

  1. Complex State Management - Applications with interconnected state
  2. Real-time Collaboration - Multi-user applications needing conflict resolution
  3. Offline-First Apps - Applications that must work offline and sync later
  4. State Machines - Applications with complex state transitions
  5. Time Travel Debugging - Applications where debugging state changes is critical
  6. Cross-Context Communication - Applications sharing state between workers/iframes
  7. TypeScript Projects - Teams valuing strong typing and compile-time safety

Feature Comparison

Feature Annette Redux MobX XState Recoil
State Management
Immutable Updates
Mutable API
Time Travel ✅* ✅*
Reactivity
Fine-grained Updates
State Machines
Distributed Sync
Algebraic Effects
Type Safety ✅*
Serialization ✅* ✅*

*With additional libraries or configuration

Getting Started

npm install annette

Then import what you need:

import {
  Agent,
  Network,
  ActionRule,
  createReactive,
  TimeTravelNetwork
} from 'annette';

You can also import from specific layers based on your needs:

// Core layer only
import { Core } from 'annette';
const network = Core.createNetwork('minimal');

// Standard library
import { StdLib } from 'annette';
const enhancedNetwork = StdLib.createEnhancedNetwork('full-featured');

// Specific features
import {
  Agent, Network, ActionRule,  // Core
  TimeTravelNetwork,           // Standard Library
  serializeValue,              // Application Layer
} from 'annette';

Contributing

We welcome contributions! See our contributing guide for details.

License

MIT License - see LICENSE for details.


Ready to build something amazing? Annette provides the foundation for creating robust, scalable applications with clear data flow and powerful abstractions. Start with the Quick Start guide and explore the examples to see what's possible! 🚀

About

A TypeScript library for crafting interaction nets

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •