Triage a record with an agent, then require a human to authorise the write

Assess an incoming record with a model, then stop and wait for a person before anything is written back to the system of record. ## Why this shape An agent that can both decide *and* act is the thing most organisations will not put into production, and no amount of prompt engineering fixes that. This flow separates the two: the model forms an opinion, a person authorises it, and only then does a single step write anything outside Orvanta. Low-risk records skip the gate automatically, so the human is only interrupted when their judgement is actually needed. That threshold is a flow input, not a constant — tune it until the escalation rate is one people will tolerate. ## Steps 1. **assess** — calls the model and returns `{ risk, rationale, recommended_action }`. The response is parsed and range-checked; a model that returns prose or a nonsense risk score fails here rather than downstream. 2. **authorise** — suspends the flow until an approver responds, or the timeout expires. Skipped entirely when `risk` is below `auto_approve_below`. 3. **apply** — POSTs the decision to your system of record. The only irreversible step, and it runs last. ## What you need before running it - An LLM API key stored as an Orvanta variable. The default path is `u/admin/AI_API_KEY`; change `api_key_variable` if yours lives elsewhere. - An HTTP endpoint that accepts the decision. Point `target_url` at a request bin first — see the write land before you aim it at anything real. ## Adapting it - **Different model or provider:** `base_url` and `model` are inputs. Anything exposing an OpenAI-compatible `/chat/completions` works unchanged. - **Routing approvals to people:** the `authorise` step returns resume URLs. Send them to Slack, Teams or email from that step — the flow does not care how the human is reached, only that one responds. - **Multiple approvers:** raise `required_events` on the suspend block. - **Reading the human's answer:** it arrives as `resume` in the step *after* the suspended one — not as `results.authorise`, which is only what that step's script returned before it suspended. This is easy to get backwards, and getting it backwards means an approver can click "reject" and see the write go through anyway. - **A system that is not HTTP:** replace the `apply` step. It is deliberately the only step that knows anything about your downstream system.

json · 265 lines
1
{
2
  "summary": "Triage a record with an agent, then require a human to authorise the write",
3
  "description": "Model assessment, a conditional human approval gate, and a single irreversible write.",
4
  "schema": {
5
    "$schema": "https://json-schema.org/draft/2020-12/schema",
6
    "type": "object",
7
    "order": [
8
      "record",
9
      "policy",
10
      "target_url",
11
      "auto_approve_below",
12
      "approver",
13
      "api_key_variable",
14
      "base_url",
15
      "model",
16
      "auth_header"
17
    ],
18
    "required": [
19
      "record",
20
      "policy",
21
      "target_url"
22
    ],
23
    "properties": {
24
      "record": {
25
        "type": "object",
26
        "description": "The record to assess — whatever your upstream system sends."
27
      },
28
      "policy": {
29
        "type": "string",
30
        "description": "The rules the agent should assess against, in plain language.",
31
        "default": "Flag anything unusual in amount, counterparty or timing. Be conservative: when in doubt, escalate."
32
      },
33
      "target_url": {
34
        "type": "string",
35
        "description": "Where the authorised decision is POSTed. Point this at a request bin first."
36
      },
37
      "auto_approve_below": {
38
        "type": "integer",
39
        "description": "Risk scores below this skip the human gate entirely. Set to 0 to require approval every time.",
40
        "default": 30
41
      },
42
      "approver": {
43
        "type": "string",
44
        "description": "Username or group that may authorise.",
45
        "default": "admin"
46
      },
47
      "api_key_variable": {
48
        "type": "string",
49
        "description": "Orvanta variable path holding the model API key.",
50
        "default": "u/admin/AI_API_KEY"
51
      },
52
      "base_url": {
53
        "type": "string",
54
        "description": "OpenAI-compatible API base URL.",
55
        "default": "https://api.openai.com/v1"
56
      },
57
      "model": {
58
        "type": "string",
59
        "description": "Model name.",
60
        "default": "gpt-4o-mini"
61
      },
62
      "auth_header": {
63
        "type": "string",
64
        "description": "Optional Authorization header for the system of record.",
65
        "default": ""
66
      }
67
    }
68
  },
69
  "value": {
70
    "modules": [
71
      {
72
        "id": "assess",
73
        "summary": "Assess the record against the policy",
74
        "value": {
75
          "type": "rawscript",
76
          "tag": "",
77
          "language": "bun",
78
          "content": "import * as orvanta from \"@orvanta/client\"\n\ntype Assessment = {\n  risk: number          // 0-100\n  rationale: string\n  recommended_action: \"approve\" | \"reject\" | \"investigate\"\n}\n\n/**\n * Ask the model to assess a record and return a decision we can branch on.\n *\n * The response is parsed and range-checked rather than trusted. A model that\n * returns prose, or a risk of 900, must fail here — loudly and before anything\n * downstream acts on it.\n */\nexport async function main(\n  record: Record<string, unknown>,\n  policy: string,\n  api_key_variable = \"u/admin/AI_API_KEY\",\n  base_url = \"https://api.openai.com/v1\",\n  model = \"gpt-4o-mini\"\n): Promise<Assessment> {\n  const apiKey = await orvanta.getVariable(api_key_variable)\n\n  const res = await fetch(`${base_url}/chat/completions`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${apiKey}`\n    },\n    body: JSON.stringify({\n      model,\n      response_format: { type: \"json_object\" },\n      messages: [\n        {\n          role: \"system\",\n          content:\n            \"You assess records against a policy. Reply with JSON only, matching \" +\n            '{\"risk\": <integer 0-100>, \"rationale\": <string>, ' +\n            '\"recommended_action\": \"approve\"|\"reject\"|\"investigate\"}.'\n        },\n        {\n          role: \"user\",\n          content: `Policy:\\n${policy}\\n\\nRecord:\\n${JSON.stringify(record, null, 2)}`\n        }\n      ]\n    })\n  })\n\n  if (!res.ok) {\n    throw new Error(`Model call failed: ${res.status} ${await res.text()}`)\n  }\n\n  const body = await res.json()\n  const raw = body?.choices?.[0]?.message?.content\n  if (typeof raw !== \"string\") {\n    throw new Error(\"Model returned no message content\")\n  }\n\n  let parsed: Assessment\n  try {\n    parsed = JSON.parse(raw)\n  } catch {\n    throw new Error(`Model did not return JSON: ${raw.slice(0, 300)}`)\n  }\n\n  // Validate rather than trust. An out-of-range risk would silently skip the\n  // approval gate below, which is the one failure this flow must never have.\n  if (typeof parsed.risk !== \"number\" || parsed.risk < 0 || parsed.risk > 100) {\n    throw new Error(`Model returned an out-of-range risk: ${JSON.stringify(parsed.risk)}`)\n  }\n  if (![\"approve\", \"reject\", \"investigate\"].includes(parsed.recommended_action)) {\n    throw new Error(`Unknown recommended_action: ${parsed.recommended_action}`)\n  }\n\n  return parsed\n}\n",
79
          "input_transforms": {
80
            "record": {
81
              "type": "javascript",
82
              "expr": "flow_input.record"
83
            },
84
            "policy": {
85
              "type": "javascript",
86
              "expr": "flow_input.policy"
87
            },
88
            "api_key_variable": {
89
              "type": "javascript",
90
              "expr": "flow_input.api_key_variable"
91
            },
92
            "base_url": {
93
              "type": "javascript",
94
              "expr": "flow_input.base_url"
95
            },
96
            "model": {
97
              "type": "javascript",
98
              "expr": "flow_input.model"
99
            }
100
          }
101
        },
102
        "timeout": 120,
103
        "retry": {
104
          "constant": {
105
            "attempts": 2,
106
            "seconds": 5
107
          }
108
        }
109
      },
110
      {
111
        "id": "gate",
112
        "summary": "Require authorisation only when the risk warrants it",
113
        "value": {
114
          "type": "branchone",
115
          "branches": [
116
            {
117
              "summary": "Risk at or above the threshold — ask a person",
118
              "expr": "results.assess.risk >= flow_input.auto_approve_below",
119
              "modules": [
120
                {
121
                  "id": "authorise",
122
                  "summary": "Wait for a human to authorise",
123
                  "value": {
124
                    "type": "rawscript",
125
                    "tag": "",
126
                    "language": "bun",
127
                    "content": "import * as orvanta from \"@orvanta/client\"\n\n/**\n * Pause for human authorisation.\n *\n * The module's `suspend` block is what actually halts the flow; this script\n * runs first and exists to put the approval links somewhere a person will see\n * them. Returning them also makes the run inspectable after the fact.\n *\n * To route approvals into Slack, Teams or email, send `urls` from here — the\n * flow does not care how the human is reached, only that one responds.\n */\nexport async function main(\n  assessment: { risk: number; rationale: string; recommended_action: string },\n  record: Record<string, unknown>,\n  approver = \"admin\"\n) {\n  const urls = await orvanta.getResumeUrls(approver)\n\n  return {\n    awaiting: approver,\n    risk: assessment.risk,\n    rationale: assessment.rationale,\n    recommended_action: assessment.recommended_action,\n    record,\n    approve_url: urls.approvalPage,\n    resume_url: urls.resume,\n    cancel_url: urls.cancel\n  }\n}\n",
128
                    "input_transforms": {
129
                      "assessment": {
130
                        "type": "javascript",
131
                        "expr": "results.assess"
132
                      },
133
                      "record": {
134
                        "type": "javascript",
135
                        "expr": "flow_input.record"
136
                      },
137
                      "approver": {
138
                        "type": "javascript",
139
                        "expr": "flow_input.approver"
140
                      }
141
                    }
142
                  },
143
                  "suspend": {
144
                    "timeout": 86400,
145
                    "required_events": 1,
146
                    "resume_form": {
147
                      "schema": {
148
                        "$schema": "https://json-schema.org/draft/2020-12/schema",
149
                        "type": "object",
150
                        "order": [
151
                          "decision",
152
                          "note"
153
                        ],
154
                        "required": [
155
                          "decision"
156
                        ],
157
                        "properties": {
158
                          "decision": {
159
                            "type": "string",
160
                            "description": "Your decision. This is what gets written downstream.",
161
                            "enum": [
162
                              "approve",
163
                              "reject"
164
                            ],
165
                            "default": "approve"
166
                          },
167
                          "note": {
168
                            "type": "string",
169
                            "description": "Optional note recorded alongside the decision.",
170
                            "default": ""
171
                          }
172
                        }
173
                      }
174
                    }
175
                  }
176
                },
177
                {
178
                  "id": "decide",
179
                  "summary": "Capture what the approver actually chose",
180
                  "value": {
181
                    "type": "rawscript",
182
                    "tag": "",
183
                    "language": "bun",
184
                    "content": "export async function main(\n  resume: { decision?: string; note?: string } | undefined,\n  approver: string\n) {\n  return {\n    decision: resume?.decision ?? \"reject\",\n    note: resume?.note ?? \"\",\n    decided_by: approver\n  }\n}\n",
185
                    "input_transforms": {
186
                      "resume": {
187
                        "type": "javascript",
188
                        "expr": "resume"
189
                      },
190
                      "approver": {
191
                        "type": "javascript",
192
                        "expr": "flow_input.approver"
193
                      }
194
                    }
195
                  }
196
                }
197
              ]
198
            }
199
          ],
200
          "default": [
201
            {
202
              "id": "auto_decide",
203
              "summary": "Below the threshold — take the agent’s recommendation",
204
              "value": {
205
                "type": "rawscript",
206
                "tag": "",
207
                "language": "bun",
208
                "content": "export async function main(recommended_action: string) {\n  return {\n    decision: recommended_action,\n    note: \"below the approval threshold\",\n    decided_by: \"auto (below threshold)\"\n  }\n}\n",
209
                "input_transforms": {
210
                  "recommended_action": {
211
                    "type": "javascript",
212
                    "expr": "results.assess.recommended_action"
213
                  }
214
                }
215
              }
216
            }
217
          ]
218
        }
219
      },
220
      {
221
        "id": "apply",
222
        "summary": "Write the decision to the system of record",
223
        "value": {
224
          "type": "rawscript",
225
          "tag": "",
226
          "language": "bun",
227
          "content": "/**\n * Write the outcome back to the system of record.\n *\n * Everything before this point is reversible; this step is not. It therefore\n * runs last, after the gate, and reports exactly what it sent.\n */\nexport async function main(\n  record: Record<string, unknown>,\n  decision: string,\n  rationale: string,\n  approved_by: string,\n  target_url: string,\n  auth_header = \"\"\n) {\n  const headers: Record<string, string> = { \"Content-Type\": \"application/json\" }\n  if (auth_header) headers[\"Authorization\"] = auth_header\n\n  const res = await fetch(target_url, {\n    method: \"POST\",\n    headers,\n    body: JSON.stringify({\n      record,\n      decision,\n      rationale,\n      approved_by,\n      decided_at: new Date().toISOString()\n    })\n  })\n\n  if (!res.ok) {\n    throw new Error(`System of record rejected the write: ${res.status} ${await res.text()}`)\n  }\n\n  return { written: true, status: res.status, response: await res.text() }\n}\n",
228
          "input_transforms": {
229
            "record": {
230
              "type": "javascript",
231
              "expr": "flow_input.record"
232
            },
233
            "decision": {
234
              "type": "javascript",
235
              "expr": "results.gate.decision"
236
            },
237
            "rationale": {
238
              "type": "javascript",
239
              "expr": "results.assess.rationale"
240
            },
241
            "approved_by": {
242
              "type": "javascript",
243
              "expr": "results.gate.decided_by"
244
            },
245
            "target_url": {
246
              "type": "javascript",
247
              "expr": "flow_input.target_url"
248
            },
249
            "auth_header": {
250
              "type": "javascript",
251
              "expr": "flow_input.auth_header"
252
            }
253
          }
254
        },
255
        "retry": {
256
          "exponential": {
257
            "attempts": 3,
258
            "multiplier": 2,
259
            "seconds": 2
260
          }
261
        }
262
      }
263
    ]
264
  }
265
}
Orvanta Hub — community scripts, flows, apps & resource types.