Anatomy of an automation file

Anatomy of an automation file #

Write a file (extension .mvt) in four passes, top to bottom: imports, constructions, declarations, entry points.

import { `@lf/email` as email, `@lf/attio` as attio } from adapters
import { acme_main } from credentials

inbox = connect email
crm   = connect attio using acme_main

function `Inbound Intake`(msg: <inbox-[:Email]->>) {
  write crm-[:Companies]-> {
    unique by (Name)
    Name: msg.Subject
  }
}

listen to inbox { key: "intake" } fire `Inbound Intake`

Imports name every external thing the file touches; constructions turn adapter types into the concrete systems ("instances") you read and write; declarations hold the automations, record structures, and file-scope values; listen lines wire an automation to what fires it (see the listeners chapter). Comments start with #.

imports #

Import from four sources, one mechanism:

import { `@lf/attio` as attio, `@lf/email` as email } from adapters
import { acme_main } from credentials
import { fetchUrl } from plugins
import { `Log Lead`, Lead as `Inbound Lead` } from "lib/intake-routines"

The quoted path is the other file's saved name, matched exactly — no folders, no extension, no case-folding; "lib/…" is a naming habit, not a directory. Importing from a file changes nothing about that file: its own listen lines keep firing it, and never fire yours.

constructions #

connect an adapter type to get an instance — the graph you actually read and write:

crm   = connect attio using acme_main
inbox = connect email

A credential is only ever named after using; the host resolves it when the instance is used. It is never a value: read anywhere else (bound to a name, in a string, compared, passed on) it is refused. A credential is typed: an Attio credential cannot connect a Slack instance. Some sources need no credential and connect without using. Which record types and fields an instance offers depends on the workspace behind the credentials, so the schema is only known once it is connected. A connection is made at file scope only. A node you declare needs no connection: it names a structure, not a system.

Every connection takes dryRun as well:

crm = connect attio using acme_main { dryRun: true }

Writes to a dry-run instance are rehearsed — captured and reported instead of committed. Three traps:

records-you-build #

Some records exist only while the run does — one assembled from several reads, or a tidied view of what arrived. Build one with a graph literal. node only declares a shape; graph only builds a value:

node Message {
  Body: <text>
  node Attachments {
    Name: <text | null>
    `Content Type`: <text>
  }
}

function `Snapshot`(m: <inbox-[:Email]->>) {
  msg = graph<Message> {
    Body: m.Body
    Attachments: m-[a:Attachments]-> {
      Name: a.Name
      `Content Type`: a.`Content Type`
    }
  }
  held = graph<Message> {
    Body: m.Body
    Attachments: m-[:Attachments]->
  }
}

collect-what-you-wrote #

Act on everything a run wrote by collecting the handles as you write: decide what to write as values, write them in a map that returns each handle, then act on the result.

deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }

lines = msg.Subject == "urgent" ? ["A big one just landed.", "Worth a look today."] : ["A new company just landed."]
sent  = map(lines, (line) => { return write deals-[:Messages]-> { Message: line } })

map(sent, (m) => {
  write m-[:Replies]-> { Message: "✅ filed" }
})

To gather records before any reach a system, declare the shape and write into its nested node:

node Found {
  node companies {
    name:    <text>
    website: <text | null>
  }
}

deduped = graph<Found> {}

map(companies, (c) => {
  write deduped-[:companies]-> { unique by (FUZZY name), name: c.name, website ?: c.website }
})

deduped-[c:companies ORDER BY c.name]-> { … }

Enrich a gathered record in place by writing to it:

map(deduped-[:companies]->, (c) => {
  details = extractOne([Json.stringify(c)], Detail)
  if (details != null) { write c { thesis: details.thesis, website ?: details.website } }
}, { concurrency: 6 })

declared-structures #

Name a structure when a callee wants to say what it takes — with the same nesting the literal uses:

node Deal {
  Title:  <text>
  Raised: <number>
  node company {
    Name: <text>
  }
}

function `Process Deal`(d: <Deal>) { … }

Extend a declaration when a later step needs more than an earlier one may see:

node `Recap Entry` extends Entry {
  diverse_founder: <text | null> "whether any founder is from an under-represented group"
}

Recap Entry is every field and nested node of Entry, then its own, and it goes anywhere Entry does. It only adds: restating a field Entry has is refused.

automations #

An automation is a function over a position — the graph point a triggering event hands it:

function `Inbound Intake`(e: <inbox-[:Email]->>) { … }

An automation wired to a listen takes exactly one parameter, the event position; a library function, called from other functions, takes any number. The body is statements — writes, traversal blocks, branches, assignments, and return. There is no loop construct; repetition comes from traversals and map.

= binds a name to a result — an instance, a write handle, an extraction, or a plain value:

greeting = "New enquiry from ${msg.From}"
company  = write crm-[:Companies]-> { … }

File-scope value bindings (a reusable prompt string, say) are allowed above the automations that use them.

functions-and-calls #

Declare a function with function. A closure bound to a name, a plugin and a built-in are functions too, all called the same way. Write your own to the convention every built-in follows:

name(data…, lambda, settings record)
function `Label`(name: <text>, tags: <text[]>, cfg: <{ prefix: text, suffix?: text }>) {
  return "${cfg.prefix}${name} (${Text.join(tags, ", ")})"
}

function `Intake`(m: <inbox-[:Email]->>) {
  title = Label(m.Subject, ["new", "inbound"], { prefix: "> " })
  write crm-[:Companies]-> {
    unique by (Name)
    Name:        m.Subject
    Description: title
  }
}

composition #

Reuse an automation by calling it, never by copying it — from the same file, or imported from a library file:

node Lead {
  Name: <text>
}

function `Log Lead`(l: <Lead>) {
  write crm-[:Companies]-> {
    unique by (Name)
    Name: l.Name
  }
}

function `Intake`(msg: <inbox-[:Email]->>) {
  `Log Lead`(graph<Lead> { Name: msg.Subject })
}

Mark a declaration export to share it — a function or a node structure. A file with at least one export is a library, and can still have listeners of its own.

returning-a-value #

Hand a result out of a body with return, and a call of that function is what it returned:

node Doc {
  title: <text>
  node files {
    name: <text | null>
    blob: <file>
  }
}

function `Email To Doc`(m: <inbox-[:Email]->>) {
  return graph<Doc> {
    title: m.Subject
    files: m-[a:Attachments]-> { name: a.Name, blob: a.File }
  }
}

function `Intake`(msg: <inbox-[:Email]->>) {
  doc = `Email To Doc`(msg)
  doc-[f:files]-> {
    write drive-[:Files]-> {
      Name: coalesce(f.name, "untitled")
      Note: doc.title
    }
  }
}

recursion #

A function may call itself, directly or through other functions — to walk a tree, an org chart, or a chain of records:

function team_size(name: <text>, people: <{ name: text, manager: text }[]>): <number> {
  reports = filter(people, (p) => p.manager == name)
  return 1 + List.sum(map(reports, (r) => team_size(r.name, people)))
}

Pitfalls #