Audience: PtcRunner maintainers and package integrators working on the host projection, schema generation, and signature implementation. End users should use the public signature reference.

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
:datetimeUTC %DateTime{} (RFC 3339 / ISO 8601 with offset)~U[2026-05-03 09:14:00Z]
:anyAny valueMatches everything

:datetime in detail

:datetime is a real semantic type, not a prettier :string. The wire form is ISO 8601 with offset (the LLM emits "2026-05-03T09:14:00Z"), but the value your code receives is an Elixir %DateTime{} struct in UTC.

LLM emitsResult
"2026-05-03T09:14:00Z"~U[2026-05-03 09:14:00Z] (UTC)
"2026-05-03T11:14:00+02:00"Shifted to ~U[2026-05-03 09:14:00Z] with a "non-UTC offset" warning
"2026-05-03T09:14:00" (no offset)Validation error — naive strings are ambiguous and rejected at the type boundary

The JSON Schema sent to the LLM provider is a plain {"type": "string"} — the format: "date-time" keyword is omitted because OpenAI's strict-mode structured output rejects unsupported keywords and would 400 the request. Local coercion validates the ISO 8601 + offset shape, so an invalid date never reaches the caller. The prompt-side example value ("2026-05-03T09:14:00Z") covers the LLM-guidance role.

When to pick :string vs :datetime:

  • :datetime if your code does anything with the value (compare, diff, format, store in a typed column).
  • :string if you only display or pass through. Cheaper to validate, no zone semantics to worry about.

Invalid Type Names (Common Mistakes)

These guessed type names do not exist:

GuessedWhat to Use Instead
:list[:type] - e.g., [:int], [:string], [:any]
:array[:type] - same as above
:tupleNo direct equivalent - use {field :type} maps with named fields
:object{field :type} or :map

Note: PTC-Lisp signatures don't have true tuples (ordered, position-based). Use maps with named fields instead, which provide better self-documentation and validation.

Example fix:

# WRONG - :list is not a valid type
signature: "(items :list) -> :bool"

# CORRECT - use [:type] syntax
signature: "(items [:any]) -> :bool"
signature: "(items [:string]) -> :bool"

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

Naming Convention: Underscores in Signatures

Signatures use underscores (Elixir/JSON convention):

signature: "(user_id :int) -> {order_count :int, is_active :bool}"

PTC-Lisp code uses hyphens (Clojure convention):

(return {:order-count 5 :is-active true})

At the tool boundary, KeyNormalizer automatically converts hyphens to underscores:

PTC-Lisp (LLM writes)Elixir receivesSignature field
:order-count"order_count"order_count
:is-active"is_active"is_active
:user-id"user_id"user_id

This allows LLMs to write idiomatic Clojure-style code while Elixir tools receive idiomatic underscore-style keys.

Why this matters:

  • LLMs trained on Clojure naturally produce hyphenated keywords
  • Elixir/JSON conventions use underscores
  • Signatures define the Elixir-side contract, so they use underscores
  • The conversion is automatic and transparent

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: (tool/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}
"""

Underscore Fields

signature: "{summary :string, _raw_data [:map]}"
# The underscore is part of the field name. It does not hide the value.

Validation Behavior

Input Validation

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

# Signature: (id :int, name :string) -> :bool
# Tool call: (tool/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.

Error Messages

Validation errors include paths for precise debugging:

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

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

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


String Keys at Tool Boundary

Important: When tools receive arguments from LLM-generated code, all map keys are strings, not atoms. This matches JSON conventions and prevents atom memory leaks.

# WRONG - pattern matching on atom keys will NOT work
def search(%{query: query, limit: limit}) do
  # ...
end

# CORRECT - use string keys
def search(%{"query" => query, "limit" => limit}) do
  # ...
end

Why String Keys?

  1. JSON compatibility - JSON only has string keys; atom keys don't survive serialization
  2. Memory safety - LLM-generated atoms could exhaust the atom table
  3. Consistency - Same convention as Phoenix params from HTTP requests

Nested Maps

String keys apply recursively to all nested maps:

# Given signature: (user {profile {name :string}}) -> :bool

# Tool receives this structure:
%{
  "user" => %{
    "profile" => %{
      "name" => "Alice"
    }
  }
}

# NOT this:
%{user: %{profile: %{name: "Alice"}}}  # WRONG - atoms

Key Normalization

Hyphens in keys are automatically converted to underscores at the boundary:

# LLM sends: {:user-name "Alice" :created-at "2024-01-01"}
# Tool receives: %{"user_name" => "Alice", "created_at" => "2024-01-01"}

This allows idiomatic Lisp (kebab-case) while providing idiomatic Elixir (snake_case).


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 - most a | b unions fall back to :any (though {:ok, t} | {:error, term} and t | nil are auto-mapped)

  • 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

Calling Tools from PTC-Lisp

Tool calls in PTC-Lisp always use named arguments — never positional. The signature parameters become the keys in a map literal:

;; Signature: (query :string, limit :int) -> [{id :int}]

;; CORRECT — map literal with named keys
(tool/search {:query "budget" :limit 10})

;; CORRECT — keyword-style (equivalent, no braces)
(tool/search :query "budget" :limit 10)

;; WRONG — positional arguments
(tool/search "budget" 10)

This is a common LLM mistake, especially with single-parameter tools:

;; Signature: (url :string) -> {text :string}

;; CORRECT
(tool/fetch_page {:url "https://example.com"})

;; WRONG — passing the string directly
(tool/fetch_page "https://example.com")

No-argument tools are called with an empty map or no arguments:

;; Signature: () -> {count :int}
(tool/get_count {})
(tool/get_count)

Why Named Arguments?

  • Self-documenting{:query "budget" :limit 10} is clearer than "budget" 10
  • Order-independent — Parameters can appear in any order
  • Extensible — Adding optional parameters doesn't break existing calls
  • JSON-compatible — Maps serialize naturally to JSON objects at the tool boundary

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