Appearance
Spend approvals
Approval of a purchase or a reimbursement, decided by rules, a model and a manager together in one durable workflow: the employee answers a request form, a blocked-category list declines what the policy never allows before any model is asked, a model reads the justification against the written spend policy, rules weigh the amount, how often the employee asks and the model's answers, and a manager takes whatever is not plainly within policy, with a one-line note the model drafts. Every answer, rule result and decision stays on the record. Pack: examples/policies/spend-approvals/.
A pattern for a spend decision, not accounting advice: the limit, the blocked category, the written policy in the model's question and the fields are placeholders chosen to make the example run; a real policy comes from the business's own rules. Its model steps call an install named chatgpt (The chatgpt install).
The decision it automates
Whether to approve the spend (approve), send it to a manager (manual_review), or decline it (reject), when the request is sent. Only the blocked list declines without a person. An approval without a person needs every rule to pass, the model's two answers among them, and a model's no sends the request to a manager rather than declining it.
Data expected
- The employee as a subject (kind
user) withnameanddepartment, which your HR system keeps current; the department names the manager's queue. - The form's answers under
submission:requestType(purchaseorreimbursement),category,vendor,amountin USD,date,justification, and thereceipta reimbursement adds. - A
spend.requestedevent for every request, this one included, posted throughPOST /events, so the frequency rule sees the history. - The
spend_blocked_categorieslist (kindstring), which the pack creates with a placeholder row,gift_cards.
Steps and rules
| Step | Type | Why |
|---|---|---|
collect | wait_for_collection | The spend-request flow; a reimbursement adds a receipt step. |
screen | evaluate_rules | spend_blocked_category alone (ruleKeys), under screen: the one rule that declines on its own runs before any model is asked. |
gate | branch | screen.outcome == reject goes to decline; any other request goes on to read. |
read | call_app | chatgpt's decide: does the justification fit the written policy, and does it describe the chosen category? Kept under ai. |
rules | evaluate_rules | The spend-approvals rule set under max_severity, over the request and the model's answers. |
route | branch | rules.outcome == manual_review drafts the note and opens a case. |
note | call_app | chatgpt's generate: one line for the manager, kept under note. |
review | create_case | spend_approval, medium priority, 48-hour SLA, labeled with the employee's department (queueFrom: subject.department). |
decide | emit_decision | From the rules, or the manager's decision. |
decline | emit_decision | From screen: an automated reject. |
The rule set spend-approvals (useCase: custom) uses max_severity: a failed block rule rejects, a failed warn rule sends the run to review, and the weights add up to the score. spend_blocked_category runs again in the set, so the decision records every rule.
| Rule | Type | Severity, weight | Fails when |
|---|---|---|---|
spend_blocked_category | list lookup | block, 100 | category is on spend_blocked_categories |
spend_amount_limit | score threshold | warn, 30 | amount is above $1,000 |
spend_frequency | velocity | warn, 20 | more than three spend.requested events in seven days |
spend_within_policy | comparison | warn, 30 | ai.answers.within_policy.value is not true: the model is less than 0.8 sure the request fits |
spend_category_matches | comparison | warn, 20 | ai.answers.category_check.value is not matches |
The blocked list is live data: finance adds or removes a category through the lists API or Define › Lists, and the next request sees it without a new version of anything. Counting requests catches a purchase split to stay under the limit; a sum of amount over the same events catches it by value.
The model steps
read asks two questions about one state, the request's requestType, category, vendor, amount, date and justification, which its input mapping puts in the step's variables (Asking for decisions):
| Question | Answer | Asks | Read by |
|---|---|---|---|
within_policy | yes or no, threshold 0.8 | Does the request fit the spend policy written in the question's instructions? | spend_within_policy |
category_check | different, unclear or matches | Does the justification describe a purchase of the chosen category? | spend_category_matches |
The written policy is the question's own text: run data fills only the state, so a justification cannot rewrite the policy. The model answers in a step rather than in an app rule, so its answers stay in the run context, where the rules read them and a backtest finds them again; an app rule would ask the model while the rules run, and a dry run or a backtest would skip it (Rules).
note asks generate for one plain sentence of at most 30 words, what the request is for and what to check, from the request, its justification and the rules' reasons (rules.reasons), with no advice to approve or decline (Asking for an answer). It runs only on the way to a manager, and it is optional: point route at review and the case opens without it.
What the vendor sees
Through the install's connection, the steps' own texts and the six answers above, with the rules' reasons for the note: not the employee's name, contact details or department, and never the receipt, since models read text and JSON only (Data protection). The employee writes the justification and may write instructions into it. The model reads it as data, and the most an answer it was talked into can do is approve a request that every other rule passes: at most $1,000, outside the blocked list, from an employee with no more than three requests in a week (Prompt injection).
The chatgpt install
A pack never carries an install: this one declares the install its steps call, "installs": { "chatgpt": "chatgpt" }, an install of the platform's ChatGPT (OpenAI API) app. The import reports chatgpt missing until the workspace has one (--publish publishes all the same), and a run that reaches read without it fails with unknown_install. The admin console's import result installs it in one click, with a preset configuration that reaches OpenAI's Responses API with storing off, for decisions too, and a sample key (Installs at import and export).
OpenAI refuses the sample key. Until an administrator stores the workspace's own OpenAI key on the install's page (Connect › Apps, the install chatgpt, Configuration › Keys › openaiApiKey), every run that reaches read fails there: it gets no decision and opens no case, and OpenAI's answer shows in the invocation record and under the run's app calls. The same holds whenever a call fails for good, note's included, through an outage that outlasts its retries, an exhausted quota or a refusal (Time, retries and failures). A request in a blocked category never reaches the model, so it is declined all the same. Once the key is stored, replay a failed run from the step that failed (Operate › Runs, Replay from step: Admin console: Runs): the employee is not asked again. Every model app takes the same input and answers in the same shape, so the steps can call another app's install instead, declared under its own name, or fall back on one; Jev decides only, so note needs one that also generates (Falling back to another install).
What a reviewer sees
A spend_approval case labeled with the employee's department, with the request, the receipt of a reimbursement, the rule results with the model's answers among them, the model's calls under Vendor checks, and the note under note.text in the run's context. The manager's decision and note become the decision and its reasons; decision.created tells your finance system to raise the purchase order or pay the reimbursement (Outbound webhooks). Request info sends the form back, and the run checks the new answers against the blocked list, reads the new justification with the model, repeats the rules and drafts a new note before the case returns (Admin console: the case page).
How to adapt it
Paste your own written policy into the instructions and criteria of within_policy, keeping it to what a justification can show, and fill the blocked list from your policy. Set a limit per category with an expression rule (data.submission.category == "equipment" && data.submission.amount > 500.0). A budget system that the rules should ask, such as what is left on the cost center, takes a call_app step through an http-api install before rules (Adding a vendor step).
Run it
bash
pnpm policy:import examples/policies/spend-approvals --publishpnpm policies:check runs three fixtures against a model stub (scripts/ai-stub.mjs), whose answers a cue word in the justification picks:
expect.json: a $405 design-team dinner on the evening of a workshop, filed undertraining, with one earlier request this week, cued low. The stub answerswithin_policyat 0.05 andcategory_checkwithdifferent, so both model rules fail while the list, limit and frequency rules pass; the note is drafted, aspend_approvalcase labeledDesignopens, the check approves it and the run completes with a manualapprove.expect-auto-approve.json: a $186.40 dinner with two buyers visiting the Chicago office, filed undermeals, cued high. The stub answerswithin_policyat 0.95 andcategory_checkwithmatches, every rule passes, and the run completes with an automatedapprove, without a note or a case.expect-blocked.json: $500 of gift cards for the customer advisory board.screendeclines the blocked category, and the run completes with an automatedrejectwithout asking the model.