Tutorial: Refund Agent
What you’ll build
A customer-support agent that:
- Reads a customer’s support ticket.
- Decides whether to refund based on a policy document (RAG-grounded).
- Asks for human approval before issuing the refund.
- Issues the refund through a payment connector.
- Writes an audit log of the decision.
By the end you’ll see every Corvid invention in one program: effects,
approvals, Grounded<T>, prompts with budgets, agent loops, and the
replay surface.
Step 1 — Project skeleton
corvid new refund-agentcd refund-agentAdd the example policy doc:
mkdir dataecho "Refunds approved up to $100 within 30 days of purchase." > data/policy.txtStep 2 — Declare the effects
src/main.cor:
effect retrieval_effect: cost: $0.001 latency: fast confidence: 0.95 data: grounded
effect llm_decision: cost: $0.02 latency: medium confidence: 0.9 trust: model_only
effect refund_effect: cost: $100.00 latency: medium trust: supervisor_required reversible: false data: external_actionThree named effects, each with the dimensions that matter for that kind of work. The compiler will use these to decide which calls need approvals, which calls need grounding, and what the budget ceiling is for the agent.
Step 3 — Write the retrieval prompt
prompt fetch_policy() -> Grounded<String> uses retrieval_effect: @retrieve("data/policy.txt")Grounded<String> is not the same type as String. A grounded value
carries provenance: which source produced it, when, and a hash. You
cannot accidentally pass a grounded value where a model-only string is
expected — the compiler refuses.
Step 4 — Write the decision prompt
prompt decide_refund( ticket: String, policy: Grounded<String>,) -> Bool uses llm_decision: "Given the policy: " + policy.unwrap_with_citation() + "\n\nDecide whether to refund this ticket: " + ticket + "\n\nReply 'yes' or 'no'."policy.unwrap_with_citation() produces a string that includes the
citation. The model sees the source. The trace records the source. The
auditor can prove the decision was grounded.
Step 5 — Write the refund tool
tool refund(amount: Float, customer_id: String) -> String uses refund_effect: @host.payment.refund(customer_id, amount)This is a real side-effect tool. Its effect row says
trust: supervisor_required and reversible: false. The compiler will
require an approve token before any reachable call site.
Step 6 — Compose the agent
agent handle_refund(ticket: String, customer_id: String) -> String: policy = fetch_policy() should_refund = decide_refund(ticket, policy) if should_refund: approve Refund(50.0, customer_id) return refund(50.0, customer_id) return "Refund denied per policy."Try compiling this:
corvid check src/main.corStep 7 — Watch the compiler catch the obvious bugs
Remove the approve line:
if should_refund: return refund(50.0, customer_id)error[E0301]: dangerous tool `refund` called without `approve` --> src/main.cor:25:16 |25 | return refund(50.0, customer_id) | ^^^^^^ | = guarantee: approval.dangerous_call_requires_tokenRestore the approve. Now make decide_refund use the policy without
unwrapping it (i.e., treat it as a regular String):
prompt decide_refund(ticket: String, policy: String) -> Bool ...error[E0412]: type mismatch --> src/main.cor:30:36 |30 | should_refund = decide_refund(ticket, policy) | ^^^^^^ expected `String`, found `Grounded<String>` | = help: use `policy.unwrap_with_citation()` to expose the value with provenance, or `policy.unwrap_discarding_sources()` to drop provenance (loses audit guarantees). = guarantee: grounded.no_silent_unwrapThe compiler refuses to silently drop provenance. If you really want
to discard it, you write that explicitly with
unwrap_discarding_sources() and the audit log will record that you
did.
Step 8 — Run with budgets
Add a budget annotation to the agent:
@budget($0.50)@max_steps(5)agent handle_refund(ticket: String, customer_id: String) -> String: ...Now the compiler knows the agent’s cost ceiling. At runtime, a budget violation aborts the agent with a typed error before the over-budget call goes out to the provider.
Step 9 — Replay
Every run produces a trace.
corvid run src/main.corcorvid trace listcorvid replay <trace-id>Replay is byte-identical: same prompts, same model responses (cached from the original run), same tool calls (intercepted, not re-executed unless you ask). This is what gives you “what changed?” in seconds when a model upgrade lands.
corvid eval --swap-model gpt-5 --source src/main.cor target/traceDiffs the new model’s behavior against the recorded baseline.
What you just shipped
A real customer-support agent with five compile-time guarantees:
- The refund tool cannot be called without an explicit approval.
- The policy passed to the model carries provenance the auditor can verify.
- The agent has a hard cost ceiling.
- The trace is replayable.
- A model upgrade is a diff, not an outage.
Read Effects to understand the dimension algebra in depth, or jump to Reference apps to see this pattern applied at production scale.