Signature Syntax

View Source

Signatures define the contract between agents and tools - what inputs they accept and what outputs they produce.

Overview

signature: "(query :string, limit :int) -> {count :int, items [{id :int}]}"

Signatures are:

  • Token-efficient - Compact syntax optimized for LLM prompts
  • Human-readable - Intuitive arrow notation for function contracts
  • Validated at runtime - Inputs and outputs are checked against the signature

Basic Structure

(inputs) -> output

Or for output-only signatures (common for top-level agents):

output

These are equivalent:

signature: "() -> {name :string, price :float}"
signature: "{name :string, price :float}"

Primitive Types

TypeDescriptionExample Values
:stringUTF-8 string"hello", ""
:intInteger42, -1, 0
:floatFloating point3.14, -0.5
:boolBooleantrue, false
:keywordKeyword/atom:pending, :active
:anyAny valueMatches everything

Collection Types

Lists

[:int]                         ; List of integers
[:string]                      ; List of strings
[:map]                         ; List of maps
[{id :int, name :string}]      ; List of typed maps

Maps with Typed Fields

{id :int, name :string}
{customer {id :int, name :string}}    ; Nested
:map                                   ; Any map (dynamic keys)

Optional Fields

Use ? suffix for optional (nullable) fields:

{id :int, email :string?}

The field can be nil or omitted entirely.


Named Parameters

Input parameters have names that become available in the signature:

signature: "(user {id :int, name :string}, limit :int) -> [{order_id :int}]"

Multiple parameters are comma-separated. The names user and limit:

  • Document what each parameter represents
  • Are validated against template placeholders in prompts
  • Appear in tool schemas shown to LLMs

Firewalled Fields

Prefix with _ to hide from LLM prompts:

signature: "{summary :string, count :int, _email_ids [:int]}"

Firewalled fields:

  • Available in Lisp context (ctx/_email_ids)
  • Available to Elixir code (step.return._email_ids)
  • Hidden from LLM prompt text (shown as <Firewalled>)
  • Hidden from parent LLM when agent is used as tool

This protects LLM context windows while preserving data flow.


Examples

Simple Output

signature: "{answer :int}"
# LLM must return: {:answer 42}

Multiple Fields

signature: "{name :string, price :float, in_stock :bool}"
# LLM must return: {:name "Widget" :price 99.99 :in_stock true}

List Output

signature: "[{id :int, title :string}]"
# LLM must return: [{:id 1 :title "First"} {:id 2 :title "Second"}]

With Inputs

signature: "(user_id :int) -> {name :string, orders [:map]}"
# Called as: (ctx/agent {:user_id 123})
# Returns: {:name "Alice" :orders [...]}

Complex Nested

signature: """
(query :string, options {limit :int?, sort :string?}) ->
{results [{id :int, score :float, metadata :map}], total :int}
"""

Firewalled Data

signature: "{summary :string, _raw_data [:map]}"
# Parent sees: {summary :string}
# Elixir gets: %{summary: "...", _raw_data: [...]}

Validation Behavior

Input Validation

When a tool is called, inputs are validated against signature parameters:

# Signature: (id :int, name :string) -> :bool
# Tool call: (ctx/check {:id "42" :name "Alice"})

# Behavior:
# 1. Coerce "42" -> 42 (string to int, with warning)
# 2. Validate "Alice" is string
# 3. Proceed with call

Output Validation

When return is called, data is validated against the return type:

# Signature: () -> {count :int, items [:string]}
# Return: (return {:count 5 :items ["a" "b"]})

# Behavior:
# 1. Validate count is int
# 2. Validate items is list of strings
# 3. Mission succeeds

# If validation fails, error is fed back to LLM for self-correction

Coercion Rules

Lenient coercion for inputs (LLMs sometimes quote numbers):

FromToBehavior
"42":int42 (with warning)
"3.14":float3.14 (with warning)
"true":booltrue (with warning)
42:float42.0 (silent)

Output validation is strict - no coercion applied.

Validation Modes

SubAgent.run(agent, signature_validation: :enabled, llm: llm)
ModeBehavior
:enabledValidate, fail on errors, allow extra fields (default)
:warn_onlyValidate, log warnings, continue
:disabledSkip all validation
:strictValidate, fail on errors, reject extra fields

Error Messages

Validation errors include paths for precise debugging:

Tool validation errors:
- results[0].customer.id: expected int, got string "abc"
- results[2].amount: expected float, got nil

Tool validation warnings:
- limit: coerced string "10" to int

Errors are fed back to the LLM for self-correction.


Type Mapping from @spec

When auto-extracting from Elixir specs:

Elixir TypeMaps To
String.t():string
integer():int
float():float
boolean():bool
atom():keyword
map():map
list(t)[:t]
%{key: type}{:key :type}

Types that require explicit signatures:

  • pid(), reference() - No JSON equivalent
  • Complex unions - {:ok, t} | {:error, term}

  • Custom @type definitions

Template Placeholders

Every {{placeholder}} in a prompt must match a signature input:

prompt: "Find emails for {{user.name}} about {{topic}}"
signature: "(user {name :string}, topic :string) -> {count :int}"

Validation happens at registration time, not runtime.

PlaceholderValid?Notes
{{name}}YesSimple variable
{{user.name}}YesNested access
{{user.address.city}}YesDeep nesting allowed
{{user-name}}YesHyphens allowed in names
{{user_name}}YesUnderscores allowed
{{123}}NoNames must start with letter
{{}}NoEmpty placeholder invalid
{{ name }}YesWhitespace trimmed

Schema Generation for Prompts

Tool schemas are rendered in the LLM prompt using signature syntax:

## Tools you can call

search(query :string, limit :int) -> [{id :int, title :string}]
  Search for items matching query.

get_user(id :int) -> {name :string, email :string?}
  Fetch user by ID. Email may be null.

Syntax Summary

Primitives:
  :string :int :float :bool :keyword :any

Lists:
  [:int]                          # list of integers
  [:string]                       # list of strings
  [{id :int, name :string}]       # list of maps

Maps:
  {id :int, name :string}         # map with required fields
  :map                            # any map (dynamic keys)

Optional (? suffix):
  {id :int, email :string?}       # email is optional

Nested:
  {user {id :int, address {city :string, zip :string}}}

Full signature:
  (param1 :type, param2 :type) -> output_type

Shorthand (no inputs):
  {count :int}                    # same as () -> {count :int}

Edge Cases

Valid Edge Cases

SignatureValid?Meaning
":any"YesAny output, no validation
"() -> :any"YesSame as above
"{}"YesEmpty map (must be a map, but no required fields)
"[]"NoInvalid - list of what? Use [:any]
"[:any]"YesList of anything
"[{}]"YesList of empty maps
""NoInvalid - empty string is not a valid signature

Nesting Depth

There is no hard limit on nesting depth, but deeply nested types should be avoided for readability:

# Valid but not recommended
{user {profile {settings {theme {colors {primary :string}}}}}}

# Prefer flatter structures or use :map for deep nesting
{user {profile :map}}

Type Coercion in Nested Structures

Coercion applies recursively to nested types:

# Signature: [{id :int, name :string}]
# Input: [%{"id" => "42", "name" => "Alice"}]
# Result: [%{id: 42, name: "Alice"}] (with coercion warning for id)

Future Considerations

Enums (v2+)

If enum types are needed, extend the shorthand syntax:

(status :enum[pending active closed]) -> {ok :bool}

Union Types (v2+)

If union types are needed:

(value :string|:int) -> {result :any}

Refinements (v2+)

If value constraints are needed:

(page :int[>0], limit :int[1..100]) -> [{id :int}]

These extensions should be added only when genuine use cases emerge.


See Also