Writing AI functions

An AI function has a name, typed inputs, typed outputs, and a description. The model writes its body every time it is called.

The parts of @ai

@ai function triage(ticket::String; product::String = "the shop")
    """
    Read a support ticket.

    # Arguments
    - `ticket`: the customer's own words
    """
    summary::String = ai"one sentence, no names"
    minutes::Int    = ai"minutes to fix"
end
AI function triage(ticket::String, product::String) -> (summary::String, minutes::Int64)
  model:        gpt-4.1-mini
  instruction:  Read a support ticket.

                Parameter guidance:
                - ticket: the customer's own words
                …
  version:      sha256:3526911f7fe9…
  see:          FunctAI.prompt(triage, …) for the exact request
  • Inputs are the arguments, positional or keyword, as in any Julia function. A default makes an input optional; it is written in the function's interface and sent to the model whenever the input is left out, so it is data: a literal or a constant whose value cannot change, the same for every call (a computed default, one that uses another input, or a constant Vector is refused when the function is defined).
  • The description is the first string of the body. A docstring's # Arguments list describes the inputs, and those words go to the model as "Parameter guidance".
  • Outputs are name::Type = ai"words" lines. The words go to the model as "Output guidance". The last output is the answer (the one ratings are about). With several, calling returns them all as a NamedTuple.
  • With no output lines, the one output is result, of the return type (String when none is written).

What the model reads is always one call away, and costs nothing:

FunctAI.prompt(triage, "I was charged twice for one order.")
model: gpt-4.1-mini
system
Function: triage

Read a support ticket.

Parameter guidance:
- ticket: the customer's own words

Output guidance:
- summary: one sentence, no names
- minutes: minutes to fix

Reply in exactly this form:
<summary>
...
</summary>
<minutes>
(integer)
</minutes>

user
<ticket>
I was charged twice for one order.
</ticket>
<product>
the shop
</product>

FunctAI.instructions(f) is the instruction alone; render(f, args...) is the lm15 request itself.

Types

A type is a promise the answer keeps. FunctAI writes each type as the JSON Schema the model is held to, the same schema Python writes for the same type, and reads the reply back as the type:

You writeThe model is asked forYou get
String, Int, Float64, Booltext, an integer, a number, true/falsethat type
an @enumone of its namesthe enum value
OneOf(:a, :b), OneOf("x", "y"), OneOf(list)one of thesethe Symbol or String
Union{T,Missing}, Union{T,Nothing}a T, or nullmissing / nothing for null
Vector{T}, Set{T}a lista Vector{T} / Set{T}
Dict{String,T}a mapa Dict
a struct, a NamedTuple typea record, every field requiredyour struct / NamedTuple
Anyany JSONJSON values (Dict, Vector, …)
a Dict (a JSON Schema)that schemaJSON values
struct Person
    name::String
    age::Union{Int,Missing}
end
print(FunctAI.LMCC.json_text(FunctAI.shape_of(Person)))    # the schema the model is held to
{"type":"object","properties":{"name":{"type":"string"},"age":{"anyOf":[{"type":"integer"},{"type":"null"}]}},"required":["name","age"]}

A reply that doesn't fit (a choice outside the list, a missing field, a fraction for an Int) is unreadable: FunctAI asks again, with the reason, up to retries times (default 1), then throws. For a type with fields that may be missing, the :json layout (adapter = :json) holds the model to the exact schema.

Code of your own

Code after the outputs runs on them, with the inputs in scope, and its value is what calling returns:

@ai function price(item::String)::Float64
    "Estimate the price in US dollars."
    usd::Float64 = ai"the price"
    round(usd; digits = 2)
end
AI function price(item::String) -> Float64
  model:        gpt-4.1-mini
  instruction:  Estimate the price in US dollars.

                Output guidance:
                - usd: the price
  code:         its own, after the model answers
  version:      sha256:bef7883354dd…
  see:          FunctAI.prompt(price, …) for the exact request

The call log records both what the model answered (usd) and what the function returned. Code of your own is part of the function's version, by its parsed form: reformatting or editing comments doesn't change it.

Without the macro

The same function, as data, for when names and types come from elsewhere (a config file, a table's columns):

@enum Mood happy unhappy mixed
mood = AIFunction("mood", "How does the customer feel about what they bought?";
                  inputs = (review = String => "the customer's own words",), output = Mood)
AI function mood(review::String) -> Main.Mood
  model:        gpt-4.1-mini
  instruction:  How does the customer feel about what they bought?

                Parameter guidance:
                - review: the customer's own words
  version:      sha256:a792402dca63…
  see:          FunctAI.prompt(mood, …) for the exact request

inputs and outputs are NamedTuples, Dicts or vectors of name => spec; a spec is a type, Type => "words", OneOf(...) or a JSON Schema Dict.

missing

A missing input is not sent: the call returns missing, for free. A default is always sent: an input left out takes its default, and a missing default is sent as null. That makes a column with holes safe to broadcast over. An answer the model may leave empty is a different thing: declare it Union{T,Missing}.

Help

?mood shows the function's signature and description, as for any documented function.