Writes, handles, and identity
Writes, handles, and identity #
Use write to create or update a record in another system.
writes #
company = write crm-[:Companies]-> {
unique by (Domains)
Name: ai("the company name this email is about")
Domains: [msg-[:Sender]->.domain]
}
- The body maps the target's writable fields to expressions — only fields it declares, so read the schema rather than guess. Fill every one the source supports, and never invent a value to fill a slot.
unique by (…)gives the record its identity, so a repeat event updates instead of duplicating....ewrites every field ofeas if each were its own line (name: e.name,stage: e.stage, …) — never its nested nodes, which are writes of their own.eis a record whose fields the automation spells out: an extracted record, a parameter typed on a declaration (d: <Deal>spreads exactlyDeal's fields), a graph you built, or a record you wrote — however the name came to hold it (afirstpick, amapparameter, a map's key:...m.k). A record read from a system is written field by field. A field the target lacks is flagged by name, and so is one that may be absent (use?...e, below). An explicit line for a field wins over the spread's value for it.- The assignment gives you a handle: a position in the target's graph carrying the written fields plus results like
externalIdandurl, read like any position (company.url). - A target may declare fields and connections its type cannot exist without; omit one and the write is flagged before it runs. Fix a missing connection by moving that parent into the write target (linked or tuple form, below); wrap a required field's
ai()incoalesce, since it can resolve to null.
what-the-write-did #
A handle reaches the record as it stood before the write through the engine-made edge -[:@old]->, so a later message can say what the write did:
was = first(company-[:@old]->)
verb = was == null ? "Added" : "Updated"
changed = was != null && was.Domains != company.Domains
| The write | company-[:@old]-> |
|---|---|
| created the record | empty, so first is null |
| matched an existing record | a snapshot: a distinct position holding the values as they stood, with the fields the lookup fetched and every field this write set |
matched, every ?: declined | a snapshot: same values, still its own position |
- A write off a record found earlier (
x = write c { … }on a traversal's landing or a matched record) is a write handle too:x-[:@old]->carries the fields the walk fetched plus those written. write org { … }updates the recordorgnames, andorgreads the new values afterwards;x = write org { … }names the same record asorg. After a second write,org-[:@old]->is the handle as it stood before that write, with its own@old:prev = first(org-[:@old]->), thenfirst(prev-[:@old]->), two steps because a hop off a call inside a formula does not parse.- So
exists(org-[:@old]->)after a second write is always true. To know whether a write created the record, read the walk right after that write, or walk the chain. - Under
dryRunevery write that matched, an in-placewrite org { … }included, lands the walk on the handle itself (first(org-[:@old]->) == org), since nothing is written; a dry-run create has an empty walk. - A field this write did not set with
:reads off the handle as the record was found. @is reserved for engine-made edges.createdandcommittedare not fields of a handle;committedis a fact of the run log.
set-if-empty #
?: sets a field only when the target's current value is empty — null or absent; an empty string counts as a value.
write crm-[:Companies]-> {
unique by (Name)
Name: msg.Subject
Description ?: "Owner: ${@user_email}"
}
Use it for defaults and one-time stamps — owners, "first seen" — and plain : where the source stays authoritative. ?: also takes a value that may not be there, no guard needed: Description ?: first(company.Domains).
?...e applies ?: to every field of e — the whole record merged into what the target already holds, filling only what is empty: write crm-[:Companies]-> { unique by (FUZZY Name), ?...e }.
append-to-a-multi-value-field #
A plain : on a multi-valued field replaces the whole list. To add to it:
Categories +: ["inbound"]
Categories +?: [ai("a one-word category for this lead")]
+: appends, duplicates and all; +?: appends only what's missing, so re-running never piles up duplicates. Both need a list — on a single-valued field they're flagged at save. Pair them with unique by, or the repeats land on different records with nothing to accumulate into.
A create writes into empty slots, so there every modifier — ?:, +:, +?: — behaves like a plain :.
structured-values #
A few fields take a structured value — an object with named keys, nested as deep as it needs, because the target publishes its own document format. Write it inline as a literal, keys separated by commas:
Layout: {
heading: "${company.Name} is ready",
blocks: [{ kind: "text", value: "Approved by ${@user_email}" }]
}
Nothing checks inside it — copy the shape from that system's documentation; a document it dislikes is its complaint at run time. Opaque means pass-through and nothing else: with no known shape the value can't be compared, added to, folded into a message, counted, or used as a condition, and each of those is refused as you write it.
identity #
unique by (`First Name`, `Last Name`)
- One clause is an AND-group — this one matches only when both do. Two clauses mean OR: match if either rule does.
- A component may be a handle rather than a field:
unique by (parent, Stage)scopes identity to a parent, the way an order is unique within its customer. FUZZYmatches a component by similarity:unique by (FUZZY Name)treats "Acme, Inc." and "Acme Inc" as one company. Use it on names and labels, never on ids or emails; not every target offers it.- A
FUZZYmatch is settled in two steps: the target surfaces the candidates by its own means, and a judge picks the best one, if any. A record the run built judges the same way. - A component may pin a value:
unique by (parent, Stage == "Seed")matches only a record whose Stage is Seed. - A component that is not an equality narrows the candidates after the lookup:
unique by (Name, Updated WITHIN 30d)matches by Name, and only a record updated in the last 30 days. With severalunique bylines, each line's test narrows only the records its own key found. - Such a test reads only the candidate's own fields, and a candidate without the field fails it unless the test is
!=. To test anything else (a hop such asEXISTS(…), a value from earlier in the run), use aWHEREon the target instead.
Identity at write time is the union of the target's own rules and your unique by, so author only the identity the target lacks: compound business keys, parent-scoped identity, fields it treats as ordinary.
match #
Use match to find a record you will not create:
company = match crm-[:Companies]-> {
unique by (FUZZY Name)
unique by (Domains)
Name: ai("the company name this email is about")
Domains: [msg-[:Sender]->.domain]
}
if (company == null) {
write crm-[:Companies]-> { unique by (Name), Name: "Unknown" }
} else {
write company-[:Notes]-> { Title: "Seen again", Content: company.Name }
}
- A named
matchis the record or absent. Test it with== null(theelsearm sees it present), or!= null; reading its fields without a guard is refused. An unnamedmatchis refused. - The identity clauses work as in
write: two clauses mean either, so this finds the company by a similar name or by its domain. - The fields are the values to match on, and nothing is written, so
?:,+:and+?:are refused. - A matched record is the old record: it has no
@old. - The handle reads like a write's. A polymorphic edge takes the type explicitly:
match a-[:related]-><Companies> { … }. - A
WHEREon the target's hop says which existing records may be matched:match crm-[c:Companies WHERE EXISTS(c-[:Team]->)]-> { … }considers only companies with someone on their team. Each candidate is read the way a traversal'sWHEREreads a record, before the identity is settled. OnwritetheWHERElimits what may be updated, never whether to write: with no candidate left, the write creates. - Use
write … unique byinstead when the record should be created if it is missing.
FUZZY works only on the fields a target lists for similarity, and the save check names them. A valuations Legal Entity lists Name, Legal Name, Also Known As and Other Names: its shortlist searches the words in all four, plus near spellings of Name and Legal Name. Every other field matches exactly, and Website ignores the scheme, www. and a trailing slash.
linked-writes #
When the new record should hang off one you just wrote, target an edge from the handle:
company = write crm-[:Companies]-> { unique by (Name), Name: enquiry.`Company Name` }
write company-[:Team]-> { Name: enquiry.`Contact Name` }
One effect, two assertions: the record and its link from company. The type comes from the edge, so the child can't be authored without its connection. Chains compose, which is why you write parents first: every child hangs off a handle already in hand.
A parent is a name or a member path: write m.k-[:Team]-> { … } hangs the child off the record a map's key holds. Bind any other expression to a name first.
The edge may be declared on either side: where only the child references back, write it at the root and link it instead. Never put a handle in a reference-shaped field (Partner: pa) — connections live in the write target, not the body.
conversation-writes #
Posting to a chat system needs no special "send": a message is a record written along an edge, the same one you read coming in. What changes is only what it hangs under — its channel or thread comes from its parent, never from a field you fill in.
deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }
write deals-[:Messages]-> { Message: "A new company just landed." }
write msg-[:Replies]-> { Message: "Got it — taking a look now." }
A reply hangs under the message that fired the automation, or one an earlier write sent back. What a system calls its message field, and which further edges it offers, is in that system's own chapter.
multi-parent-writes #
Some records sit at the convergence of several parents, none primary. Write the record once, with a tuple of paths:
write (co-[:agreements]->, pa-[:agreements]->) {
unique by (co, pa)
Value: msg.Value
}
Each path is a handle plus one edge its type declares, and the written type must be the same for every path: one record carrying N connections, not N records, created in one effect. unique by (co, pa) scopes its identity by all the parents at once.
link #
link connects two records without creating or modifying either one.
link champion -[:led]-> part
to = match p-[:Company]-> { unique by (FUZZY Name), Name: "Acme" }
if (to != null) { link p -[:Company]-> to }
The first form joins two handles in the same graph, over an edge the from-side declares. To link a record you look up, match it first, test it, then link. Name selects Acme, it doesn't rename anything. A link or unlink whose end may be absent (a match handle or a first(…) result) needs the guard too.
looking-up-existing-records #
Look up a record that must already exist with only, and guard it:
company = only(crm-[c:Companies WHERE c.Domains CONTAINS "acme.com"]->)
if (company == null) { fail("no company at acme.com") }
write company-[:Notes]-> { Title: "Enriched", Content: "found ${company.Name}" }
The hop's WHERE is the criteria, rooted at any instance in scope. The guard ends the run with a reason when nothing matches.
So: a lookup finds a record that must be there, and a miss fails the run; match finds one that may not be, and a miss is absent; write … unique by finds or creates; write <alias> updates the exact record you already hold.
updating-a-record-in-place #
crm-[record:Companies WHERE record.Categories == "Lead"]-> {
write record { Categories: "Customer" }
}
write <alias> { … } updates the record the alias already stands on — traversed, or a write's handle, which then reads the new values (a match's handle stays the record as found) — and write m.k { … } updates the one a map's key holds, as x = m.k then write x { … } would. Nothing is left to resolve, so unique by is rejected, and there is no create to gate: set only the fields that change, and use link/unlink for connections. A target that can't update by id (an append-only channel, a drive that only creates files) is flagged at save.
removal #
unlink company -[:related]-> stale # sever the edge; both records remain
delete stale # remove the record itself
unlink is the exact inverse of link's handle form; severing an edge that isn't there is a quiet no-op. delete removes the record a position stands on, so it works wherever an in-place update does — reach for it only when removal is the point, never as cleanup for an identity you got wrong. Both are capability-gated: a target that can't sever or delete rejects the statement up front.