English
Operations
An operation is one change to a template: add, move, delete or update a block, update the settings, or replace the whole content. The same six operations travel everywhere a template changes outside a single editor:
- Collaboration — an editor with
init({ collaboration })sends every edit to your backend as a batch of operations, and applies other people's batches. - The CLI —
template-tools editapplies them to a template file. - Templatical Cloud — stores every batch with its revision, and AI agents connected to its MCP server edit with the same vocabulary through
edit_template.
applyOperations from @templatical/types (MIT) applies them. Templatical Cloud's server runs an engine with the same rules, and a shared set of test cases keeps the two identical.
The six operations
json
{"op": "addBlock", "block": {"id": "b2", "type": "paragraph", "content": "<p>Hi</p>"}, "parent": null, "after": "b1"}
{"op": "moveBlock", "blockId": "b2", "parent": {"sectionId": "s1", "column": 0}, "after": null}
{"op": "deleteBlock", "blockId": "b2"}
{"op": "updateBlock", "blockId": "b1", "set": {"content": "<p>Hello</p>"}, "unset": ["visibility"]}
{"op": "updateSettings", "set": {"width": 640}, "unset": ["preheader"]}
{"op": "setContent", "content": {"blocks": [], "settings": {"width": 600}}}A batch is a list of operations. It applies in order, each operation seeing the ones before it, and all or nothing: when one is refused, none of the batch applies.
Positions
Positions name a sibling, never an index, so an operation means the same thing after someone else's change lands first.
parentisnullfor the top level, or{"sectionId": "…", "column": 0}for a column of a section.columnis a 0-based index into the section'schildren.afteris the id of the block to land after, ornullto land first. FormoveBlock, the siblings are those of the target container with the moved block taken out.parentandafterare required onaddBlockandmoveBlock, even when they arenull.
Sections and columns
- A section sits only at the top level. Adding or moving a section into a column is refused, and so is a new section with a section among its children.
deleteBlockon a section deletes its blocks with it.updateBlockreplaces each key insetwhole, astylesobject included, and removes the keys inunset.id,typeandchildrencan't be set or unset, and neither can a section'scolumnsbe unset.set: {"columns": …}on a section resizes itschildrento the layout's column count:"1"has one column,"3"has three, and every other layout ("2","2-1","1-2") has two. Growing appends empty columns. Shrinking drops trailing columns only when they are empty, so move their blocks out first.- A new block needs a non-empty string
idand a stringtype, and every id in it, a new section's children included, must be new to the template.
Preconditions
Any operation but addBlock and setContent can carry expect, which must hold before it applies. A mismatch refuses the operation with precondition_failed. Undo uses this, so an undo that someone else's edit has overtaken is refused instead of overwriting it.
updateBlockandupdateSettings:expectmaps keys to their expected current values.nullmatches anullvalue or an absent key.moveBlock:expect.parentandexpect.afterare the block's current position. A key left out counts asnull.deleteBlock:expect.blockis the whole current block, children included, compared exactly.- Values compare as JSON: object key order is ignored, arrays compare in order, and numbers by value, so
1equals1.0.set,unsetandexpectgiven asnullcount as absent.
Locks
A backend passes the locks it holds and the acting session: applyOperations(content, ops, { locks, session }), with locks as [{ blockId, sessionId }].
updateBlock,moveBlockanddeleteBlockon a block another session holds are refused withlock_held. Moving or deleting a section also checks every block inside it.setContentis refused withlocked_by_otherswhile any other session holds a lock.- With
sessionset tonull(a server-side change), every lock belongs to someone else.
Errors
A refused batch throws an OperationError with code and opIndex, the 0-based index of the refused operation.
| Code | When | Templatical Cloud answers |
|---|---|---|
block_exists | A new block's id, or an id inside it, is already in the template | 409 |
block_missing | The block, or the parent section, isn't there | 409 |
anchor_missing | The after block isn't in the target container | 409 |
precondition_failed | expect doesn't hold | 409 |
lock_held | Another session holds the block | 409 |
locked_by_others | Another session holds a lock, for setContent | 409 |
invalid_operation | The operation is malformed or can never apply | 422 |
Which error wins
When one operation breaks several rules, the first failing check decides:
- Its shape (
invalid_operation): a malformed operation, a missing required key, a new section with a parent,id/type/childreninsetorunset, a section'scolumnsinunset. - The blocks it names (
block_missing,block_exists). - Locks (
lock_held,locked_by_others). - Preconditions (
precondition_failed). - Placement (
invalid_operation): a section into a column, a block after itself. - The target container:
block_missingfor a missing parent section;invalid_operationfor a parent that isn't a section or a column out of range. - The anchor (
anchor_missing). - The change itself (
invalid_operation): a layout that would drop columns that still hold blocks.
Applying operations
ts
import {
applyOperations,
OperationError,
type Operation,
} from '@templatical/types';
const ops: Operation[] = [
{ op: 'updateBlock', blockId: 'b1', set: { content: '<p>Hello</p>' } },
];
try {
const next = applyOperations(template.content, ops, {
locks: [{ blockId: 'b4', sessionId: 'f7c1…' }],
session: requestSessionId,
});
// store `next`
} catch (error) {
if (error instanceof OperationError) {
// error.code, error.opIndex
}
throw error;
}applyOperations returns a new document and never changes the one you pass. applyOperationsInPlace changes it directly, for a copy you can throw away. getColumnCount(layout) is the layout rule above.
In the CLI
bash
npx -y @templatical/template-tools edit emails/welcome.json \
--op '{"op":"updateBlock","blockId":"button_1","set":{"backgroundColor":"#1d4ed8"}}'--ops <file> takes a JSON array for a batch. See Template Tools.