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) -> outputOr for output-only signatures (common for top-level agents):
outputThese are equivalent:
signature: "() -> {name :string, price :float}"
signature: "{name :string, price :float}"Primitive Types
| Type | Description | Example Values |
|---|---|---|
:string | UTF-8 string | "hello", "" |
:int | Integer | 42, -1, 0 |
:float | Floating point | 3.14, -0.5 |
:bool | Boolean | true, false |
:keyword | Keyword/atom | :pending, :active |
:datetime | UTC %DateTime{} (RFC 3339 / ISO 8601 with offset) | ~U[2026-05-03 09:14:00Z] |
:any | Any value | Matches 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 emits | Result |
|---|---|
"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:
:datetimeif your code does anything with the value (compare, diff, format, store in a typed column).:stringif 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:
| Guessed | What to Use Instead |
|---|---|
:list | [:type] - e.g., [:int], [:string], [:any] |
:array | [:type] - same as above |
:tuple | No 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 mapsMaps 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 receives | Signature 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 callOutput 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-correctionCoercion Rules
Lenient coercion for inputs (LLMs sometimes quote numbers):
| From | To | Behavior |
|---|---|---|
"42" | :int | 42 (with warning) |
"3.14" | :float | 3.14 (with warning) |
"true" | :bool | true (with warning) |
42 | :float | 42.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 integerErrors 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
# ...
endWhy String Keys?
- JSON compatibility - JSON only has string keys; atom keys don't survive serialization
- Memory safety - LLM-generated atoms could exhaust the atom table
- 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 - atomsKey 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 Type | Maps 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 equivalentComplex unions - most
a | bunions fall back to:any(though{:ok, t} | {:error, term}andt | nilare auto-mapped)- Custom
@typedefinitions
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.
| Placeholder | Valid? | Notes |
|---|---|---|
{{name}} | Yes | Simple variable |
{{user.name}} | Yes | Nested access |
{{user.address.city}} | Yes | Deep nesting allowed |
{{user-name}} | Yes | Hyphens allowed in names |
{{user_name}} | Yes | Underscores allowed |
{{123}} | No | Names must start with letter |
{{}} | No | Empty placeholder invalid |
{{ name }} | Yes | Whitespace 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
| Signature | Valid? | Meaning |
|---|---|---|
":any" | Yes | Any output, no validation |
"() -> :any" | Yes | Same as above |
"{}" | Yes | Empty map (must be a map, but no required fields) |
"[]" | No | Invalid - list of what? Use [:any] |
"[:any]" | Yes | List of anything |
"[{}]" | Yes | List of empty maps |
"" | No | Invalid - 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.