Octov0.11.7
ReferenceCEL

Octo Extensions

Variables and custom functions Octo adds to the CEL environment.

Every message expression in Octo is compiled in the same environment: the standard CEL library, seven cel-go utility libraries, six message variables, and the custom functions below. The same catalogue is served to AI tooling by the Octo MCP server's getCelFunctions tool.

Variables

These are in scope for every message expression: block settings, conditions, foreach items, validation rules, and so on.

VariableTypeDescription
bodydynThe decoded message body, in JSON-native shapes (map, list, string, double, bool, null). In raw-content mode it is the {contentType, rawData} envelope.
varsmap(string, dyn)The message's variables: values set by the source (path params, captured headers, query) and by blocks such as set-variable, multi-transform, and enrich.
eventIDstringThe message's unique event id.
correlationIDstringThe message's correlation id (empty unless the source or a block set one), for tracing a message across flows.
envmap(string, string)The resolved environment variables the integration declared, e.g. env.API_BASE_URL. Referencing an unresolved name is an evaluation error (no such key).
nowtimestampThe evaluation time. Use string(now) to render it into a JSON body, or arithmetic like now - duration("1h").
bin/octo eval --expr '"hi " + body.name + " (" + eventID + ")"' --data '{"name": "Ada"}'
# {"ok":true,"result":"hi Ada (3c1658e009c837440266d967b87dc5e4)"}

An ai-agent tool's authorize expression sees these six plus tool (with name and id) and input, the call's decoded arguments.

Source payload expressions

A source's payload expression (for example on a cron source) runs before any message exists, so it sees a smaller scope: only now (the trigger's fire time) and settings (the source's static settings map). The message variables above are not available there.

Every function below, templateResource included, is available in a payload, resolved against that same scope.

source:
  connector: ticker
  type: cron
  settings:
    schedule: "@every 3s"
    payload: '{"firedAt": string(now)}'

Functions

Octo registers eighteen functions of its own. They are available in every message expression, in every source payload, and inside a template's {{ }} spans. The three environments expose the same set, as do the extension libraries.

toJson

toJson(dyn) -> string

Marshals any value to a compact JSON string. Useful for embedding a structure into a string field such as a log line, an LLM prompt, or an outgoing text body.

bin/octo eval --expr 'toJson({"a": 1, "b": [true, null]})'
# {"ok":true,"result":"{\"a\":1,\"b\":[true,null]}"}

fromJson

fromJson(string) -> dyn

Parses a JSON string into a decoded value (map, list, double, string, bool, or null). The typical use is reverting a captured raw body: an HTTP source with rawBody: true delivers the exact request bytes as body.rawData, and fromJson(body.rawData) turns them back into a structured value.

bin/octo eval --expr 'fromJson("{\"a\": 1}")'
# {"ok":true,"result":{"a":1}}

bin/octo eval --expr 'fromJson("[1, 2, 3]")[0]'
# {"ok":true,"result":1}

toFormData

toFormData(dyn) -> string

Encodes an object as an application/x-www-form-urlencoded string. Each field must be a scalar; a list field emits a repeated key. Keys are sorted, so the output is deterministic. For multipart/form-data, see toMultipart.

bin/octo eval --expr 'toFormData({"q": "hello world", "page": 2, "tags": ["a", "b"]})'
# {"ok":true,"result":"page=2\u0026q=hello+world\u0026tags=a\u0026tags=b"}

\u0026 is how the JSON envelope escapes &; the encoded string itself holds a plain &.

fromFormData

fromFormData(string) -> dyn

Parses an application/x-www-form-urlencoded string into an object. A single value becomes a string; repeated keys become a list of strings. The typical use is decoding a raw form POST: fromFormData(body.rawData).

bin/octo eval --expr 'fromFormData("q=hello+world&page=2&tags=a&tags=b")'
# {"ok":true,"result":{"page":"2","q":"hello world","tags":["a","b"]}}

Parsed form values are always strings, so compare with int(body.page) > 1 or body.page == "2".

fromMultipart

fromMultipart(dyn) -> dyn              # a raw-content body
fromMultipart(string, string) -> dyn   # (rawData, contentType)

Decodes a multipart/form-data payload into the parts map: part name to decoded part, with a repeated name collecting into a list.

The http source decodes multipart automatically onto body.parts, so reach for this when a multipart payload arrives some other way: off a queue, out of a file, or in a rest response.

One decoded part looks like this:

{
  "name": "avatar",
  "filename": "photo.png",
  "contentType": "image/png",
  "encoding": "base64",
  "size": 12345,
  "data": "iVBORw0K...",
  "headers": {}
}

Each part carries its own contentType, independent of the request's outer content type, so an upload declares itself a PNG rather than a CSV. name and filename come from the part's Content-Disposition; headers holds whatever else the part declared.

A part with a filename is always base64; a plain form field is always text. The rule follows the payload's shape, not its bytes, so an expression means the same thing on every request. Decode binary data with base64.decode:

base64.decode(body.parts.avatar.data)

size is always the decoded byte length, whichever encoding the part uses.

bin/octo eval --data '{"contentType":"multipart/form-data; boundary=b","rawData":"--b\r\nContent-Disposition: form-data; name=\"who\"\r\n\r\nann\r\n--b--\r\n"}' \
  --expr 'fromMultipart(body).who.data'
# {"ok":true,"result":"ann"}

multipart

multipart() -> dyn

Returns an empty parts map to build on. Pair it with addPart.

addPart

<parts>.addPart(string, dyn) -> dyn

Returns a new parts map with one part added. It never modifies the map it is called on, so an expression can branch without one path affecting the other.

A scalar value is shorthand for a text field. An object gives full control, and may name only the keys it cares about; encoding defaults to text.

multipart()
  .addPart("caption", body.caption)
  .addPart("report", {"data": vars.csv, "filename": "r.csv", "contentType": "text/csv"})

A decoded part is already the shape addPart accepts, so forwarding an upload keeps its filename and content type:

multipart().addPart("avatar", body.parts.avatar)

And because each call returns a value, parts can be added conditionally, which a static configuration map cannot express:

(has(body.caption) ? multipart().addPart("caption", body.caption) : multipart())
  .addPart("avatar", body.parts.avatar)
bin/octo eval --data '{}' --expr 'multipart().addPart("a", "1").a.data'
# {"ok":true,"result":"1"}

toMultipart

toMultipart(dyn) -> string
toMultipart(dyn, string) -> string

Renders a parts map as a multipart/form-data body. Part names are written in sorted order, so the output is deterministic.

The rest block builds multipart bodies itself: set bodyType: multipart and let it own the boundary. Use toMultipart when the body is going somewhere else, such as a file write or a queue publish.

The second argument names the boundary. Without it the boundary is the fixed octo-multipart, not a random one, because set-payload takes its contentType as static configuration and you have to write the matching boundary yourself:

- type: set-payload
  settings:
    rawBody: true
    contentType: 'multipart/form-data; boundary=octo-multipart'
    value: 'toMultipart(body.parts)'
bin/octo eval --data '{}' --expr 'toMultipart(multipart().addPart("a", "1"), "b")'
# {"ok":true,"result":"--b\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--b--\r\n"}

toYaml

toYaml(dyn) -> string

Renders any value as a YAML document, for writing a config file or handing a structure to a tool that reads YAML.

Strings that would read back as a different type are quoted for you, so what toYaml writes is what fromYaml returns. Under YAML 1.1 a bare y, no or 1.0 is a boolean or a number, not the string you put in.

bin/octo eval --expr 'toYaml({"a": 1, "b": ["x", "y"]})'
# {"ok":true,"result":"a: 1\nb:\n    - x\n    - \"y\"\n"}

fromYaml

fromYaml(string) -> dyn

Parses a YAML document into a decoded value. The typical use is a YAML file read into the flow as raw content: fromYaml(body.rawData).

The result is normalized to the JSON-native shapes a message body may hold: YAML integers become numbers and YAML timestamps become RFC 3339 strings. A document YAML can express but JSON cannot, such as a non-string mapping key, is an evaluation error rather than a body with a foreign type in it.

A JSON number is a binary double, so an integer above 2^53 does not survive exactly, and id: 9007199254740993 reads back as 9007199254740992. This is the body contract rather than anything YAML-specific (fromJson does the same), but YAML config files carry large ids more often. Quote such a value in the source document to keep it a string.

bin/octo eval --expr 'fromYaml("a: 1\nb: [x, y]")'
# {"ok":true,"result":{"a":1,"b":["x","y"]}}

toEnv

toEnv(dyn) -> string

Renders a flat object as .env file content. Keys are sorted, so the output is deterministic, and a value is quoted only when writing it bare would not read back the same: when it is empty, holds whitespace or a #, or contains a quote, a backslash, or a line break.

Values are scalars (a string, number or boolean, with null rendering empty). A scalar may hold any characters, line breaks included: every one that carries structure is escaped, so one variable is always one physical line and a value can never introduce a second assignment. Untrusted text is safe to feed in.

Two things are errors rather than silent corruption. An env file is flat, so a nested object or list value is refused, naming the key. And a name has no quoted form to hide in, so a name that is empty, contains =, #, or a quote, or starts or ends with whitespace is refused too.

bin/octo eval --expr 'toEnv({"B": "two words", "A": "1"})'
# {"ok":true,"result":"A=1\nB=\"two words\"\n"}

fromEnv

fromEnv(string) -> dyn

Parses .env file content into an object. Blank lines and # comments are skipped, a leading export is dropped, and quoted values are unquoted. These are the same rules the runtime uses for the .env files it loads at startup, because it is the same parser.

Every value is a string, since an env file carries no types. Compare with body.PORT == "8080" or convert with int(body.PORT).

bin/octo eval --expr 'fromEnv("export A=1\n# note\nB=\"two words\"")'
# {"ok":true,"result":{"A":"1","B":"two words"}}

templateResource

templateResource(string) -> string

Renders a template resource against the current message, returning the rendered text. Name it by its resource id or by the alias declared under resources.templates[].as. The template sees every in-scope variable: {{ body.* }}, {{ vars.* }}, {{ env.* }}, and so on. A missing or unparsable template declared in the config fails at load time, not per message.

A template is a shared resource, so its {{ }} spans may name any variable from any scope it can be rendered in. Rendering it from a source payload gives it that scope instead: {{ settings.* }} and {{ now }}. A span naming a variable the current scope does not supply ({{ body.x }} in a payload) reads as null rather than failing, so write templates against the scope you render them from.

templateResource is itself available inside a template, so templates compose: a page template can pull in a shared header with {{ templateResource("header") }}. Nesting is capped at 8 levels, so a template that renders itself fails with a clear error instead of recursing without end.

resources:
  templates:
    - resource: templates/welcome-email.tmpl
      as: welcome

flows:
  - name: send-welcome
    process:
      - type: set-payload
        name: render
        settings:
          value: '{"subject": "Welcome!", "text": templateResource("welcome")}'

hmacSha256

hmacSha256(dyn, dyn) -> bytes

The HMAC-SHA256 of a payload under a key, as raw bytes. Both arguments take a string or bytes. Rendering is a separate step, so the same function serves a hex scheme and a base64 one.

bin/octo eval --expr 'hexEncode(hmacSha256("key", "hello"))'

hmacSha1

hmacSha1(dyn, dyn) -> bytes

The same, with SHA-1. It is here for legacy schemes that still sign with it (Twilio, and GitHub's older X-Hub-Signature) and should not be chosen for anything new.

hexEncode

hexEncode(dyn) -> string

Renders bytes as lowercase hex, the form most providers put in their signature header. For the ones that use base64, base64.encode from the encoder library already applies.

uuid

uuid() -> string

A random version 4 UUID, for when a message needs an identifier and nothing upstream supplied one: a correlation id on an outbound request, an idempotency key, a synthetic id for a record that arrived without one.

- type: rest
  settings:
    connector: payments
    method: POST
    path: /charges
    headers:
      Idempotency-Key: uuid()
    body: '{"amount": body.amount}'

uuid() is the second non-deterministic thing in the language, after now. An expression containing it evaluates to something different every time, so a replayed trace does not reproduce, a validate rule written on it cannot be reasoned about, and a cache key built from it never hits. Use it where a fresh value is the point.

It is the wrong tool for naming a conversation: an ai-agent's memoryThreadId is evaluated once per run, so a minted thread loads a transcript nobody wrote and saves one nobody will read, which is what leaving the field out already does without the writes. A tool branch that wants a scope of its own is handed vars.toolScope.

secureCompare

secureCompare(dyn, dyn) -> bool

Compares two values without a content-dependent early return, and it is what makes the rest safe to offer.

CEL's own == on strings stops at the first differing byte, and how long it took to stop is observable, which over enough requests is a way to learn the expected signature one byte at a time. secureCompare does not. It does not hide the lengths, which is fine: for a fixed-width digest they are equal anyway.

Never compare a signature with ==. Use secureCompare for anything an attacker can submit repeatedly and observe the timing of.

toAes

toAes(dyn, dyn) -> bytes

Encrypts a value with AES-GCM under a key, returning the sealed bytes. They carry their own nonce, so nothing has to be stored beside them, and sealing the same plaintext twice gives different bytes — equal ciphertexts would leak that the values were equal.

The key is 16, 24 or 32 bytes, and its length is what selects AES-128, AES-192 or AES-256. Rendering is a separate step, the same split hmacSha256 uses, so the result goes through base64.encode or hexEncode before it travels.

bin/octo eval --expr 'base64.encode(toAes("078-05-1120", "0123456789abcdef0123456789abcdef"))'

fromAes

fromAes(dyn, dyn) -> bytes

Reverses toAes. The result is bytes: wrap it in string() for text, and fromJson(string(...)) to get a structured value back.

AES-GCM is authenticated, so a value sealed under a different key, or altered since it was sealed, fails the expression rather than decoding into something plausible. That failure fails the block, which is the right default. No rule ahead of it can screen for it either — whether a value is authentic is what fromAes runs to find out — so where the ciphertext arrives from outside, wrap the block in handle-errors and answer from its error chain.

- type: set-payload
  settings:
    value: 'fromJson(string(fromAes(base64.decode(body.sealed), base64.decode(env.CRYPTO_KEY))))'

toChacha

toChacha(dyn, dyn) -> bytes

The same as toAes with ChaCha20-Poly1305, which takes a 32-byte key and nothing else. Choose it where AES has no hardware acceleration, or where a counterparty asked for it; otherwise toAes is the default.

fromChacha

fromChacha(dyn, dyn) -> bytes

Reverses toChacha. The two algorithms are not interchangeable: opening AES-GCM bytes with fromChacha fails, as it should.

The key is an argument because a CEL function cannot reach a connector — it sees only what it is passed. Write it as env.CRYPTO_KEY, never as a literal in a flow file, and remember that a key held as base64 has to be base64.decode'd first: pass it without decoding and its text becomes the key, which is both wrong and, at 44 characters, not a valid length anyway.

For asymmetric encryption, or to keep the key out of the flow file entirely, use the crypto connector and its encrypt/decrypt blocks instead.

templateResource needs a loaded config to resolve resources, so it cannot be exercised with standalone octo eval, where any id reports load template "...": resource: not found. Every other function on this page works in eval exactly as in a flow.

Verifying a webhook signature

Four of the functions exist for one job: authenticating an inbound webhook.

Every provider worth verifying (GitHub, Slack, Stripe, Notion, Shopify) signs some arrangement of the request with a shared secret and puts the digest in a header. What differs between them is only which bytes are signed, how the digest is rendered, and what prefixes it, all three of which an expression can already say. So these are primitives rather than a block per provider, and a scheme nobody has implemented yet is a validate rule away.

- type: validate
  name: verify-signature
  rejectStatus: 401
  rules:
    - expr: >
        "X-Hub-Signature-256" in vars &&
        secureCompare(
          vars["X-Hub-Signature-256"],
          "sha256=" + hexEncode(hmacSha256(env.GITHUB_WEBHOOK_SECRET, vars.rawBody)))
      message: invalid webhook signature

Three things about that rule are load-bearing.

vars.rawBody is the exact request bytes, captured by the http source's rawBodyVar. A signature computed over the parsed body would never match, because re-serializing reorders keys and drops whitespace.

The prefix is compared as part of the signature, not stripped from the incoming one, so a request that omits it fails rather than passes.

The in guard turns a missing header into a rejection rather than a failure. Reading an absent variable is an evaluation error, which fails the flow and answers 500, the one status a webhook sender retries for hours. Guarded, it is a plain false, and a false here is the 401 you wanted. has() will not do: it takes a field selection, and a header name with dashes can only be reached as an index.

See samples/github-webhook.yaml for the whole flow. Other providers differ only in the expression:

ProviderSigned payloadRendering
GitHubvars.rawBody"sha256=" + hexEncode(...)
Slack"v0:" + vars["X-Slack-Request-Timestamp"] + ":" + vars.rawBody"v0=" + hexEncode(...)
Stripe<timestamp> + "." + vars.rawBodyhexEncode(...), from the compound t=…,v1=… header
Shopifyvars.rawBodybase64.encode(...)

Slack and Stripe also bound replay by timestamp, which now already expresses: compare it against the request's timestamp before checking the HMAC. Slack and Notion additionally have dedicated blocks (slack-verify-request, notion-verify-request) that do all of this for you; reach for these functions when no block covers your provider.

Behavior notes

Results are JSON-native. Whatever a CEL expression produces is bridged to JSON shapes: maps become objects, all numbers become JSON numbers, bytes render as base64, timestamps as RFC 3339 strings, durations as seconds. An optional resolves to the value it holds, or to null when it holds none.

Expressions compile when the flow is built, so a typo fails the deployment (or the run --watch reload), never a live message. An evaluation error fails the message instead: a missing key, a division by zero, or a failed conversion becomes a block error, which the flow's error path can handle.

On this page