Imp 01: Real LM Front Door
imp_checkout? = fn path ->
is_binary(path) and File.regular?(Path.join(path, "mix.exs")) and
File.regular?(Path.join(path, "lib/imp.ex"))
end
explicit_repo = System.get_env("IMP_PATH")
if explicit_repo && not imp_checkout?.(Path.expand(explicit_repo)) do
raise "IMP_PATH does not point to an Imp source checkout or unpacked package"
end
repo =
[explicit_repo, Path.expand("..", __DIR__), File.cwd!()]
|> Enum.reject(&is_nil/1)
|> Enum.map(&Path.expand/1)
|> Enum.find(imp_checkout?)
if repo do
# Prefer the notebook's own Imp checkout even when Livebook was launched
# from an unrelated Mix project. Unpacked package archives may omit
# mix.lock, so pin it only when the source checkout actually provides it.
install_opts =
if File.regular?(Path.join(repo, "mix.lock")),
do: [lockfile: Path.join(repo, "mix.lock")],
else: []
Mix.install([{:imp, path: repo}], install_opts)
else
# Standalone notebook: install the tagged Git source release.
Mix.install([{:imp, github: "deepfates/imp", tag: "v0.4.0"}])
end
api_key = System.get_env("OPENAI_API_KEY") || System.get_env("LB_OPENAI_API_KEY")
model = System.get_env("OPENAI_MODEL") || System.get_env("LB_OPENAI_MODEL") || "gpt-5.4-mini"
setup_guidance = """
This notebook makes real model calls, and no OpenAI key is loaded yet.
1. Get an API key at https://platform.openai.com/api-keys
2. In Livebook, open the Secrets panel (lock icon) and add a secret named
OPENAI_API_KEY — or export OPENAI_API_KEY before starting Livebook.
3. Re-run from the top. A full pass through this notebook costs a few cents
with #{model}.
Until then, each live cell below returns this reminder instead of a result.
"""
live_lm = fn opts ->
if api_key do
{:ok,
Imp.req_llm("openai:" <> model,
Keyword.merge([api_key: api_key, max_completion_tokens: 400], opts)
)}
else
:missing_key
end
end
if api_key, do: "Live model ready: #{model}", else: IO.puts(setup_guidance)
Program, Don't Prompt
This notebook is the Imp front door. It mirrors the core idea from the README: declare a task, run it as a program with a real model, then grow the same task into tools, evaluation, optimization, and persistence.
Manual path: real provider shape first, deterministic development next, evaluation and optimization after that, then tools and operations.
Extract Structured Data
Start with a signature. It names the inputs and outputs that the rest of the system can test, evaluate, optimize, and persist. Run the cell and read the result: a real model turning an email into named fields.
case live_lm.(max_completion_tokens: 200) do
{:ok, lm} ->
extract =
Imp.predict(
Imp.signature(
"email -> event_name: string, date: string",
"Extract the event name and date from the email."
),
lm: lm,
adapter: Imp.Adapter.JSON,
config: [json_retries: 1]
)
{:ok, prediction} =
Imp.call(extract, %{
email: "Team Offsite moved to Thursday, June 5. Bring questions for planning."
})
Imp.to_map(prediction)
:missing_key ->
"Add OPENAI_API_KEY (see the setup cell) to watch this run."
end
Change The Module, Keep The Task
The task contract stays stable while the execution strategy changes.
Imp.chain_of_thought/2 asks the model to reason before answering — same
signature idea, one extra declared output field:
case live_lm.(max_completion_tokens: 400) do
{:ok, lm} ->
classify =
Imp.chain_of_thought(
Imp.signature(
"ticket -> reasoning: string, urgency: enum[low,high], team: string",
"Route the support ticket."
),
lm: lm,
adapter: Imp.Adapter.JSON,
config: [json_retries: 1]
)
{:ok, prediction} =
Imp.call(classify, %{
ticket: "Production checkout is failing for all EU customers."
})
Imp.to_map(prediction)
:missing_key ->
"Add OPENAI_API_KEY (see the setup cell) to watch this run."
end
Add Conversation History
History is prior task data keyed by the signature fields. Imp renders it as prior turns for the model, while the program still receives named inputs and returns named outputs:
case live_lm.(max_completion_tokens: 200) do
{:ok, lm} ->
qa =
Imp.predict(
Imp.signature(
"question, history -> answer: short_span",
"Answer the latest question using the conversation history when relevant."
),
lm: lm
)
history =
Imp.history([
%{question: "What is the capital of France?", answer: "Paris"},
%{question: "What country is Paris in?", answer: "France"}
])
{:ok, prediction} =
Imp.call(qa, %{question: "What city did we identify first?", history: history})
Imp.to_map(prediction)
:missing_key ->
"Add OPENAI_API_KEY (see the setup cell) to watch this run."
end
Add Tools With ReAct
Only add tools when the task needs action outside the model. Imp.react/3
keeps tool use inside an explicit policy, and the reserved submit tool
validates the signature, so the loop cannot bypass the output contract:
case live_lm.(max_completion_tokens: 400) do
{:ok, lm} ->
lookup =
Imp.tool(:lookup, "Look up a fact by query.", fn args ->
case args[:query] || args["query"] do
"capital-france" -> "Paris"
other -> {:error, {:unknown_query, other}}
end
end,
schema: %{
"type" => "object",
"properties" => %{"query" => %{"type" => "string", "enum" => ["capital-france"]}},
"required" => ["query"]
}
)
react =
Imp.react(
Imp.signature(
"question -> answer",
"Answer using the lookup tool: first call lookup with query \"capital-france\", then call submit with the answer it returned."
),
[lookup],
lm: lm,
tool_policy: [:lookup, :submit],
max_iters: 4
)
case Imp.call(react, %{question: "What is the capital of France?"}) do
{:ok, prediction} -> Imp.to_map(prediction)
{:error, reason} -> {:model_did_not_finish, reason}
end
:missing_key ->
"Add OPENAI_API_KEY (see the setup cell) to watch this run."
end
Evaluate And Compile
Metrics and optimizers work on the same program values you just ran. This
cell is deliberately provider-free: a scripted Imp.LM.Static stands in for
the model so you can prove your metric and optimizer wiring instantly and for
free — this is also how your test suite exercises Imp programs. Livebook 03
expands it into the full evaluation and optimization workflow:
static_lm = Imp.LM.Static.new(handler: fn _messages, _opts -> %{answer: "Paris"} end)
program = Imp.predict("question -> answer", lm: static_lm)
trainset = [
Imp.example(question: "Eiffel Tower city?", answer: "Paris")
|> Imp.with_inputs(:question)
]
devset = [
Imp.example(question: "Capital of France?", answer: "Paris")
|> Imp.with_inputs(:question)
]
metric = Imp.exact_match(:answer)
compiled =
program
|> Imp.optimize!(
Imp.Optimizer.RandomSearch.new(metric, candidates: 2, demos_per_candidate: 1),
trainset,
devset
)
{Imp.evaluate(program, devset, metric).score, Imp.evaluate(compiled, devset, metric).score}
Save Without Secrets
Persistence stores the program shape and safe configuration, never live
credentials. Imp.dump/1 returns a portable data representation; loading it
back gives a callable program you rebind to a live model at runtime. Livebook
05 returns to this from the operations side:
case live_lm.([]) do
{:ok, lm} ->
program = Imp.predict("question -> answer", lm: lm)
state = Imp.dump(program)
%{
credential_in_artifact?: inspect(state) =~ api_key,
loadable?: match?(%{__struct__: Imp.Predict.Predict}, Imp.load(state))
}
:missing_key ->
"Add OPENAI_API_KEY (see the setup cell) to watch this run."
end
Next: open livebooks/02_programming_not_prompting.livemd to run the same
programming model without provider calls and inspect the generated messages.