---
title: "Cookbook: Function calling"
type: cookbook
tags: [cookbook, function-calling, choice, noul, dispatch]
created: 2026-09-17
updated: 2026-09-17
confidence: high
sources:
  - raw/docs/cookbooks__function_calling.md
jev_version: "jev-1.13.0"
sdk_python: "0.6.0"
summary: "Map typed Python functions to Jev Choice/Noul questions so a natural-language command becomes a validated call with a per-argument confidence."
---

# Cookbook: Function calling

> **TL;DR** Every function argument whose type is a `Literal` is a closed set, so give it a `Choice` question over exactly those values and an optional `stated` `Noul` that decides whether the argument was mentioned at all. One request carries the function choice plus every function's arguments; the dispatcher reads only the winner's answers, and the call's `confidence` is the *minimum* per-argument probability, not the product.

## Goal

Turn a sentence such as `"plot rolling correlation between nvda and spy for the past month"` into `rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo')` with confidence `0.91`, calling ten ordinary typed Python functions in a trading assistant. The cookbook's framing: a barista does not write your sentence down, they mark four options on a cup.

Because Jev only ever picks among the options you hand it, whatever reaches the function is a value the function accepts. You do not modify the functions; you add a spec that says in plain words what each argument means.

## Inputs / state shape

The **state** is the user's command string. The **functions** are the schema. Signature (verbatim):

```python
def plot_price(
    symbol: Literal["SPY", "NVDA", "AMD", "AAPL", "MSFT", "TSLA"],
    style: Literal["line", "candles"] = "line",
    resolution: Literal["1m", "5m", "15m", "1h", "1d"] = "15m",
    window: Literal["1d", "1w", "1mo", "3mo"] = "1w",
    include_volume: bool = False,
    moving_average: Literal["9", "20", "50"] | None = None,
    log_scale: bool = False,
): ...
```

`closed_sets(fn)` reads a signature and sorts arguments into three shapes: **choice** (a `Literal`, one value), **set** (a `list[Literal[...]]`, any number), **flag** (a `bool`). Across the 10 functions there are **28 fillable arguments**; `list_symbols` has 0, `plot_price` has 7. `top_movers`'s `limit` is an `int`, so it gets no question and keeps its default of 3 — free text, numbers and dates behave the same way.

The second input is `spec.json`, one entry per argument (an LLM can write it from the signatures). Verbatim excerpts:

```json
{
  "style": {
    "question": "Does the user want a plain line or candles?",
    "stated": "Does the user say how the chart should be drawn, such as a line, candles, or OHLC bars?",
    "options": {
      "line": "a simple line through the closing prices",
      "candles": "a candlestick or OHLC chart, showing each bar's open, high, low and close"
    }
  }
}
{
  "moving_average": {
    "question": "How many bars should the moving average cover - nine, twenty, or fifty?",
    "stated": "Does the user ask for a moving average or a smoothed line over the candles?",
    "options": {
      "9": "a nine-bar moving average, a fast one",
      "20": "a twenty-bar moving average",
      "50": "a fifty-bar moving average, a slow one"
    }
  }
}
```

The option keys are the strings the function takes, so nothing has to map a label back to an argument afterwards.

## Questions asked

`Dispatcher` builds **54 questions per command** from the spec once. Four of them, exactly as the cookbook prints them (`qid`, `type`, `instructions`):

| `qid` | `type` | `instructions` |
|---|---|---|
| `__tool__` | `choice` | `What is the user asking the trading assistant to do?` |
| `plot_price.style` | `choice` | `Does the user want a plain line or candles?` |
| `plot_price.style?` | `noul` | `Does the user say how the chart should be drawn, such as a line,` … (truncated at 64 chars in the source output) |
| `compare_returns.symbols.NVDA` | `noul` | `Does the user want NVDA in the comparison?` |

Structure of the question set:

- `__tool__` — one `Choice` over the ten function descriptions. Its answer picks the function.
- `<fn>.<arg>` — a `Choice` whose criteria are the argument's `options` (key → description).
- `<fn>.<arg>?` — the `stated` `Noul`, which makes the argument optional. When the answer is no, the call leaves the argument out and the function's own default applies.
- `<fn>.<arg>.<MEMBER>` — a set argument gets its question once per member, with `{}` standing in for the member name: `"Does the user want {} in the comparison?"` becomes one question per ticker.

Writing advice from the cookbook: write each question about the *idea* rather than the words a user might pick, because the match is on meaning — `"is amd tracking nvidia lately"` reaches `rolling_correlation` even though neither *tracking* nor *lately* appears anywhere in `spec.json`. Avoid naming a question after its parameter; `"Which resolution?"` gives the command nothing to match against.

## Combining logic in code

The dispatcher sends one request carrying the function choice and every function's arguments, then reads only the chosen function's answers:

```python
import json
from pathlib import Path

from dispatch import ROUTE, Dispatcher, closed_sets
from trader import TOOLS, client, load

TYPESAFE_MODEL = "jev-1.12"

SPEC = json.loads(Path("spec.json").read_text())
assistant = Dispatcher(SPEC, TOOLS, client)

call = assistant("plot rolling correlation between nvda and spy for the past month")
print(call)                 # rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo')
print(f"{call.confidence:.2f}")   # 0.91
print(f"{call.tool.probability:.2f}")
call.run()                  # actually invokes the function
```

Reading one call apart, argument by argument:

```python
call = CALLS["is amd tracking nvidia lately"]
for name, argument in call.arguments.items():
    top = sorted(argument.distribution.items(), key=lambda kv: -kv[1])[:3]
    shown = "omitted, default stands" if argument.omitted else repr(argument.value)
    print(
        f"  {name:<12}{shown:<26}p {argument.probability:.2f}   "
        + "  ".join(f"{k} {v:.2f}" for k, v in top)
    )
print(f"  weakest argument: {call.weakest().name}")
```

```
"is amd tracking nvidia lately"  ->  rolling_correlation(symbol='AMD', benchmark='NVDA')   confidence 0.82
  symbol      'AMD'                     p 0.87   AMD 0.87  NVDA 0.13  AAPL 0.00
  benchmark   'NVDA'                    p 0.78   NVDA 0.92  AMD 0.08  AAPL 0.00
  window      omitted, default stands   p 0.96
  resolution  omitted, default stands   p 0.99
  weakest argument: benchmark
```

`confidence` reports **the least certain judgement in the call, rather than the product of all of them**, since one wrong argument is enough to spoil the result. A product answers a different question ("is every part right"), and it falls as a function takes more arguments whether or not any one judgement is shaky.

`dispatch.py` and `trader.py` sit beside the notebook and are not reproduced upstream, so the `Dispatcher`/`closed_sets` bodies are not available (inferred: you write them yourself from the description above).

## Results / what the cookbook reports

Fourteen commands, each one request. Selected rows verbatim:

| command | call | confidence | tool p |
|---|---|---|---|
| `show nvda 1h` | `plot_price(symbol='NVDA', resolution='1h')` | 0.78 | 1.00 |
| `plot rolling correlation between nvda and spy for the past month` | `rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo')` | 0.91 | 1.00 |
| `when during the day does nvda trade the most` | `intraday_pattern(symbol='NVDA')` | 0.53 | 1.00 |
| `what tickers do you have` | `list_symbols()` | 1.00 | 1.00 |
| `candles for tesla with a 20 period moving average` | `plot_price(symbol='TSLA', style='candles', moving_average='20')` | 0.69 | 0.97 |
| `compare nvda amd and msft over the past three months` | `compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo')` | 0.94 | 1.00 |
| `biggest losers today` | `top_movers(window='1d', direction='losers')` | 0.98 | 0.98 |
| `worst drawdown for nvda this quarter, and chart it please` | `drawdown(symbol='NVDA', window='3mo', plot=True)` | 0.84 | 0.84 |
| `show me apple daily with volume` | `plot_price(symbol='AAPL', resolution='1d', include_volume=True)` | 0.75 | 0.85 |
| `is amd tracking nvidia lately` | `rolling_correlation(symbol='AMD', benchmark='NVDA')` | 0.82 | 0.82 |

The rolling-correlation command filled four arguments from one sentence; `symbol` and `benchmark` draw from the same six tickers and each ticker landed in the right argument because the questions spell out the roles (*the one being measured, named first* against *the second one named, the yardstick*). The published run used `TYPESAFE_MODEL = "jev-1.12"` over `156,780` one-minute bars.

## Adapting it to a new domain

1. Keep your functions as they are; make sure closed-set arguments really are `Literal`s, `list[Literal[...]]`, or `bool`.
2. Write `spec.json`: a `question` per argument, an `options` map whose keys are the literal values, a `description` per function, a `stated` question for every argument that should be optional, and one question that picks between functions.
3. Point `Dispatcher(SPEC, TOOLS, client)` at your own `TOOLS` dict.
4. Threshold on `call.confidence` and inspect `call.weakest()` to decide whether to run, confirm, or ask back.

## Gotchas

- **`stated` is what keeps defaults alive.** Without it the `Choice` would have to name *some* window and would have named one confidently; "lately" says nothing about a window, so `rolling_correlation` runs on its own defaults (one month, hourly bars).
- **Arguments with open types get no question**, so their function defaults silently stand. `top_movers(limit: int)` is never filled from the sentence.
- **Confidence is a minimum**, so it is only as good as the weakest argument's question. `intraday_pattern(symbol='NVDA')` at 0.53 is the sort of row you route to a confirmation prompt (see [[patterns/confidence-routing]]).
- **Install line.** The cookbook uses `pip install ipython polars matplotlib numpy "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/`. `cooksafe` is a helper package on TypeSafe's private index, and `pypi.typesafe.ai` returned **404 publicly as of 2026-09-17** — install `pip install typesafe-sdk` instead and reimplement the helpers you need. Here that is `make_playground_link(state, questions, models=[...])`, which builds a `https://console.typesafe.ai/playground#share/...` URL, and (in `trader.py`) a response cache.
- The published code pins `TYPESAFE_MODEL = "jev-1.12"`; this wiki documents `jev-1.13.0`, so re-running live may shift probabilities.

## Related

- [[concepts/choice]] — the primitive behind every argument question
- [[concepts/noul]] — the `stated` question type
- [[concepts/confidence]] — what `confidence` means and how to threshold it
- [[patterns/intent-routing]] — the same shape when the target is a handler, not a function
- [[patterns/fan-out]] — why all 54 questions ride in one request
- [[cookbooks/skill-suggestion]] — ranking a large option set then re-reading the top few
- [[cookbooks/overview]] — the full cookbook catalog

## Sources

- raw/docs/cookbooks__function_calling.md (https://docs.typesafe.ai/cookbooks/function_calling.md)
