You write the call in the program. There is nothing to import and nothing to construct.
answer = ai "What is the capital of France?"
answer is text. When you need a value you can add, match, or store, name the type:
count = ai[Int] "How many words are in: hello there world"
print(count + 1)
count is an int, so + is arithmetic. A bool, an enum, and a record use the same brackets:
type Mood = Happy | Sad
mood = ai[Mood] "Classify the sentiment of: what a lovely day"
type Person = Person { name: String, age: Int }
who = ai[Person] "Extract the person from: Ada, 36"
print(who.name)
mood is one of the two variants, so a match on it is exhaustive. who.age is an int. If the reply cannot be coerced into the type, the call retries with the failure fed back, then gives up. The field list and the retry rule are on typed output.
One sample is one opinion. Five samples, and the value that wins:
best = ai[Mood] 5 "Classify the sentiment of: what a lovely day"
A function the call can use
A tool is a function you already wrote, plus a sentence the model is allowed to see.
@tool("look up the current weather for a city")
fn weather(city) = "sunny in {city}"
report = ai "What is the weather in Oslo?" using [weather]
weather runs in your program. The string on @tool is the description the model reads, so write it for someone who cannot see the body. Every name in using needs that annotation. A tool that errors comes back as text the model can try to recover from, and the loop stops after ECKO_AI_MAX_TOOL_ROUNDS rounds (the default is 8). Tools has the argument rules.
More than one turn
chat = session()
ai "My name is Ada." with chat
who = ai "What is my name?" with chat
with sends the earlier turns as separate messages, then stores this prompt and the reply. It does not glue the transcript into one user message. The history is a cell, so you can read it or keep the last few turns:
cell_update(chat, fn(msgs) if len(msgs) > 20 { msgs[-20..] } else { msgs })
Sessions is the rest of that.
While it is still writing
story = ai "Write a short story" -> stream
for chunk in story {
print_no_newline(chunk)
}
An untyped stream yields text as it arrives. Ask for a type as well and the call finishes the value first, then chunks that result:
story = ai[String] "Write a short story" -> stream
A half-parsed answer is not a string you can trust, so the chunks start after coercion has succeeded. Streaming covers both.
No key on the machine
Unset ECKO_API_KEY and the file still runs. The call returns a fixed stand-in instead of failing:
| Call | Result |
|---|---|
ai "..." | [AI Mock] followed by the prompt |
ai[Int] | 42 |
ai[Mood] | Happy, the first variant |
ai[Person] | a Person, each field filled the same way |
ecko test clears the key, the provider, and the base URL before it runs anything, so a test can assert on those values. Which provider you use, which model, and the cap in ECKO_AI_MAX_CALLS are environment variables. They stay out of the source. Mock mode lists the stand-in for every type.