Concepts

Proposals, stale proposals, partial accepts and the tools an agent gets.

Every Cube component sits on the same core, usecube. You need it directly when you build your own component, when your agent runs on a server, or when you want to know exactly what happens on Accept.

The store

A store holds plain JSON: a sheet's rows, a doc's blocks, a board's cards. People change it directly. Agents can only propose.

import { cube } from 'usecube'

const budget = cube(
  { cells: { A1: 'Item', B1: 'Q1', A2: 'Hosting', B2: 1200 } },
  { name: 'budget', about: 'A spreadsheet. Each cell lives at /cells/{A1-style id}.' },
)

snapshot() gives you three things: state, what has been accepted; preview, what you would get by accepting everything; and proposals, the changes waiting for a person.

Proposals

A proposal is a JSON Patch (RFC 6902), a reason and an author. JSON Patch is the format AG-UI uses for state changes, so nothing here is new to a model.

budget.propose({
  why: 'Add Q2',
  by: 'Claude',
  ops: [{ op: 'add', path: '/cells/C2', value: 1350 }],
})

Each proposal is an independent change to the accepted state, like a pull request against main.

Accept and reject

Nothing changes until a person accepts. Rejecting drops the proposal.

const { proposals } = budget.snapshot()

budget.accept(proposals[0].id)
budget.reject(proposals[1].id)

// a person's own edit applies right away
budget.edit([{ op: 'replace', path: '/cells/B2', value: 1250 }])

Partial accept

A proposal can change many places at once. Pass only to accept or reject the part at some of them; the rest stays pending as the same proposal.

// the agent filled in five cells; keep two of them
budget.accept(id, { only: ['/cells/D2', '/cells/D3'] })

// and throw one away
budget.reject(id, { only: ['/cells/D5'] })

Stale proposals

A proposal remembers what it saw at every place it writes, including "this was empty". If a person changes one of those places first, that part goes stale: an agent never overwrites a person's newer edit. The proposal lists those places in outdated and can't be accepted whole; its other changes still show and can be accepted with only.

budget.propose({
  why: 'Fill in Q3',
  ops: [
    { op: 'add', path: '/cells/D2', value: 1500 },
    { op: 'add', path: '/cells/D3', value: 900 },
  ],
})
budget.edit([{ op: 'add', path: '/cells/D2', value: 1400 }]) // a person got to D2 first

const [p] = budget.snapshot().proposals
p.stale // true
p.outdated // ['/cells/D2']
budget.accept(p.id) // throws: p1 is out of date: /cells/D2 changed after it was proposed
budget.accept(p.id, { only: ['/cells/D3'] }) // fine

One proposal per place

Two pending proposals can't touch the same place. To revise, the agent proposes again with replaces, and the new proposal takes the old one's place. If it forgets, the error tells it how.

{ "why": "Fill in Q3, lower", "ops": [{ "op": "add", "path": "/cells/D2", "value": 1450 }], "replaces": "p1" }

Tools

tools() gives any model two tools, named after the store: budget_read and budget_propose. They come in the shape MCP and most SDKs use: { name, description, inputSchema, execute }. The store's about goes into their descriptions, so the model knows where things live.

// budget_read
{ "path": "/cells/D2" }                      // omit path to read everything
{ "value": 1500, "pending": [{ "id": "p1", "by": "Claude", "why": "...", "ops": [...], "stale": false, "outdated": [] }] }

// budget_propose
{ "why": "Fill in Q3", "ops": [{ "op": "add", "path": "/cells/D2", "value": 1500 }] }
{ "id": "p2", "status": "waiting for review" }
{ "error": "p1 already proposes a change at /cells/D2; to revise it, propose again with replaces: \"p1\"" }

Tools never throw on a bad proposal. They return { error } in plain English so the model can read it and try again. Calling store.propose() yourself throws a CubeError instead.

Validation

Pass any Standard Schema (Zod, Valibot, ArkType) as schema. A proposal that would break it is refused with the reason, before a person ever sees it.

import { z } from 'zod'

const budget = cube(initial, {
  name: 'budget',
  schema: z.object({ cells: z.record(z.string(), z.union([z.string(), z.number()])) }),
})

Rendering

at(pointer) gives one place everything it needs to draw itself: the accepted value, the next value and the proposals touching it. A cell, a block, a card or a form field renders the same way.

import { useCube } from 'usecube/react'

function Cell({ id }: { id: string }) {
  const { value, next, proposals } = useCube(budget).at(`/cells/${id}`)
  if (!proposals.some((p) => !p.stale)) return <td>{value}</td>
  return (
    <td className="suggested">
      <del>{value}</del> <ins>{next}</ins>
    </td>
  )
}

Model your state for agents

Give everything an address that never moves. Use keyed objects, not arrays: /rows/r7, not /rows/3. An agent that inserts at index 3 shifts everything after it, and models get indexes wrong. Keep order in a field (order: 2.5) and sort when you render.