@invokehq/cli 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +887 -0
- package/agentify.py +1785 -0
- package/bin/invoke.js +74 -0
- package/package.json +19 -25
- package/pyproject.toml +28 -0
- package/LICENSE +0 -21
- package/index.js +0 -421
- package/trace.js +0 -62
package/README.md
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
# Invoke
|
|
2
|
+
|
|
3
|
+
Real-world execution for AI agents.
|
|
4
|
+
|
|
5
|
+
Invoke sits between AI agents and production tools so agent actions do not become wrong CRM updates, duplicate charges, stale approvals, or impossible debugging sessions.
|
|
6
|
+
|
|
7
|
+
Agents can reason. Production breaks when they execute.
|
|
8
|
+
|
|
9
|
+
Invoke turns every tool call into a controlled execution:
|
|
10
|
+
|
|
11
|
+
- validate schema, scope, and policy
|
|
12
|
+
- block wrong-entity actions before they touch the tool
|
|
13
|
+
- retry safe failures without custom glue
|
|
14
|
+
- reconcile unknown outcomes before retrying
|
|
15
|
+
- prevent duplicate side effects with idempotency
|
|
16
|
+
- freeze risky work for approval, then revalidate before execution
|
|
17
|
+
- trace what happened end to end
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. Install the CLI
|
|
23
|
+
npm install -g @invokehq/cli
|
|
24
|
+
|
|
25
|
+
# 2. Authenticate to your Invoke runtime
|
|
26
|
+
invoke login --base-url https://agentgate-ai.onrender.com --api-key inv_live_...
|
|
27
|
+
|
|
28
|
+
# 3. Scaffold an execution project
|
|
29
|
+
invoke init support-agent --template crm-guardrail
|
|
30
|
+
cd support-agent
|
|
31
|
+
|
|
32
|
+
# 4. Run its local MCP server
|
|
33
|
+
invoke dev
|
|
34
|
+
|
|
35
|
+
# 5. Register its tools with Invoke
|
|
36
|
+
invoke deploy
|
|
37
|
+
|
|
38
|
+
# 6. Call a tool through the execution layer
|
|
39
|
+
invoke call crm_update_customer '{"customer_id":"cust_123","account_status":"review"}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then call tools through one execution layer:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Invoke } from "./sdk";
|
|
46
|
+
|
|
47
|
+
const invoke = Invoke.fromEnv();
|
|
48
|
+
|
|
49
|
+
await invoke.call({
|
|
50
|
+
tool: "linear.create_issue",
|
|
51
|
+
params: {
|
|
52
|
+
team_id: "team_123",
|
|
53
|
+
title: "Investigate webhook drift",
|
|
54
|
+
},
|
|
55
|
+
agentId: "prod-support-agent",
|
|
56
|
+
idempotencyKey: "linear:Investigate webhook drift:team_123",
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That's it. Your agent still chooses the action. Invoke makes the execution reliable: scoped, checked, retried, reconciled, approved, and traced.
|
|
61
|
+
|
|
62
|
+
## What You Can Build
|
|
63
|
+
|
|
64
|
+
Invoke ships with wrapper generation for common production tools:
|
|
65
|
+
|
|
66
|
+
| Command | What it creates |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `invoke wrap github` | GitHub MCP wrapper with approval-ready issue creation metadata |
|
|
69
|
+
| `invoke wrap linear` | Linear issue workflow wrapper with idempotency hints |
|
|
70
|
+
| `invoke wrap notion` | Notion page/document wrapper with Invoke capability metadata |
|
|
71
|
+
| `invoke wrap postgresql --query "SELECT ..."` | Scoped PostgreSQL query tool with inferred input schema |
|
|
72
|
+
| `invoke wrap billing-api --openapi openapi.json --base-url https://billing.example.com` | OpenAPI-backed wrapper for an internal service |
|
|
73
|
+
|
|
74
|
+
Or start from an existing MCP server and register its capability card with Invoke:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
invoke init billing-agent --template default
|
|
78
|
+
cd billing-agent
|
|
79
|
+
# run a local MCP endpoint, or edit invoke.json to point at your hosted MCP URL
|
|
80
|
+
invoke dev
|
|
81
|
+
invoke deploy
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## See The Aha Moment
|
|
85
|
+
|
|
86
|
+
Run the local failure demo:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
./demo/run_demo.sh
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It starts mock tools and shows six production failure modes:
|
|
93
|
+
|
|
94
|
+
- tool timeout recovered with bounded retry
|
|
95
|
+
- payment timeout reconciled before a duplicate charge
|
|
96
|
+
- wrong CRM update blocked before the record is touched
|
|
97
|
+
- duplicate retry replayed instead of creating a second issue
|
|
98
|
+
- stale approval requeued after live state changed
|
|
99
|
+
- webhook inconsistency returned as `replan_required`
|
|
100
|
+
|
|
101
|
+
The buyer takeaway is simple:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
Invoke does not make agents smarter.
|
|
105
|
+
It makes their execution bounded when production gets messy.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How It Works
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
Agents call tools Invoke controls execution Production systems
|
|
112
|
+
+----------------+ +------------------------+ +------------------+
|
|
113
|
+
| SDK / HTTP | | Scope + schema check | | Linear |
|
|
114
|
+
| MCP clients |----->| Retry + reconciliation |----->| Slack |
|
|
115
|
+
| workflows | | Approval + revalidate | | CRM / billing |
|
|
116
|
+
| background jobs|<-----| Trace + outcome |<-----| Internal APIs |
|
|
117
|
+
+----------------+ +------------------------+ +------------------+
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Define - Wrap a service with `invoke wrap` or register an existing MCP tool with a capability card, schema, risk level, and retry/idempotency hints.
|
|
121
|
+
|
|
122
|
+
Execute - Agents call `/call` through the SDK or HTTP. Invoke validates the request, checks scope, classifies the action, and routes it to the right tool.
|
|
123
|
+
|
|
124
|
+
Control - If the tool times out, partially succeeds, or returns an unknown outcome, Invoke reconciles current state before retrying. If the action is risky, Invoke freezes it for approval and revalidates live state before execution.
|
|
125
|
+
|
|
126
|
+
Trace - Every call gets a structured execution record your team can inspect, export, and debug.
|
|
127
|
+
|
|
128
|
+
## SDK And API
|
|
129
|
+
|
|
130
|
+
### 1. Get an API key
|
|
131
|
+
|
|
132
|
+
Ask for an Invoke API key, then export it in your shell:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
export INVOKE_API_KEY="inv_or_ag_live_..."
|
|
136
|
+
export INVOKE_BASE_URL="https://agentgate-ai.onrender.com"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every API request uses:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
X-API-Key: $INVOKE_API_KEY
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 2. Check available tools
|
|
146
|
+
|
|
147
|
+
This confirms your key works and shows the tools Invoke can route to.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
curl "$INVOKE_BASE_URL/v1/tools" \
|
|
151
|
+
-H "X-API-Key: $INVOKE_API_KEY"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 3. Retrieve live context with Exa
|
|
155
|
+
|
|
156
|
+
This is what we mean by `curl /v1/search`: your agent asks Invoke for fresh docs or web context before acting. Invoke calls Exa server-side and returns normalized sources plus a trace.
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
curl -X POST "$INVOKE_BASE_URL/v1/search" \
|
|
160
|
+
-H "Content-Type: application/json" \
|
|
161
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
162
|
+
-d '{
|
|
163
|
+
"query": "latest MCP agent failures",
|
|
164
|
+
"limit": 3
|
|
165
|
+
}'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 4. Create a safe execution
|
|
169
|
+
|
|
170
|
+
This is what we mean by `curl /v1/executions`: your agent asks Invoke to run a workflow with an idempotency key. If the request is repeated after a timeout, Invoke replays the completed execution instead of creating duplicate side effects.
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
curl -X POST "$INVOKE_BASE_URL/v1/executions" \
|
|
174
|
+
-H "Content-Type: application/json" \
|
|
175
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
176
|
+
-H "Idempotency-Key: demo-charge-001" \
|
|
177
|
+
-d '{
|
|
178
|
+
"workflow": "safe-tool-execution",
|
|
179
|
+
"agent_id": "revops_agent",
|
|
180
|
+
"input": {
|
|
181
|
+
"params": {
|
|
182
|
+
"customer_id": "cust_acme",
|
|
183
|
+
"amount": 2400,
|
|
184
|
+
"currency": "usd"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}'
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The response includes a durable execution object:
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"success": true,
|
|
195
|
+
"execution": {
|
|
196
|
+
"execution_id": "exec_142",
|
|
197
|
+
"workflow_id": "safe-tool-execution",
|
|
198
|
+
"status": "completed",
|
|
199
|
+
"idempotency_key": "demo-charge-001",
|
|
200
|
+
"final_outcome": "completed",
|
|
201
|
+
"trace": [
|
|
202
|
+
{"step": "request_received", "status": "completed"},
|
|
203
|
+
{"step": "unknown_outcome_reconciled", "status": "completed"},
|
|
204
|
+
{"step": "duplicate_retry_blocked", "status": "completed"}
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 5. Use the SDK
|
|
211
|
+
|
|
212
|
+
Python:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from sdk import Invoke
|
|
216
|
+
|
|
217
|
+
invoke = Invoke.from_env()
|
|
218
|
+
|
|
219
|
+
context = invoke.search("latest MCP agent failures", limit=3)
|
|
220
|
+
print(context["results"][0]["url"])
|
|
221
|
+
|
|
222
|
+
execution = invoke.execute(
|
|
223
|
+
workflow="safe-tool-execution",
|
|
224
|
+
agent_id="revops_agent",
|
|
225
|
+
idempotency_key="demo-charge-001",
|
|
226
|
+
input={
|
|
227
|
+
"params": {
|
|
228
|
+
"customer_id": "cust_acme",
|
|
229
|
+
"amount": 2400,
|
|
230
|
+
"currency": "usd",
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
print(execution["execution"]["execution_id"])
|
|
236
|
+
print(execution["execution"]["final_outcome"])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
TypeScript:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { Invoke } from "./sdk";
|
|
243
|
+
|
|
244
|
+
const invoke = Invoke.fromEnv();
|
|
245
|
+
|
|
246
|
+
const context = await invoke.search("latest MCP agent failures", { limit: 3 });
|
|
247
|
+
console.log(context.results);
|
|
248
|
+
|
|
249
|
+
const execution = await invoke.execute({
|
|
250
|
+
workflow: "safe-tool-execution",
|
|
251
|
+
agentId: "revops_agent",
|
|
252
|
+
idempotencyKey: "demo-charge-001",
|
|
253
|
+
input: {
|
|
254
|
+
params: {
|
|
255
|
+
customer_id: "cust_acme",
|
|
256
|
+
amount: 2400,
|
|
257
|
+
currency: "usd",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
console.log(execution.execution);
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 6. Run packaged workflows
|
|
266
|
+
|
|
267
|
+
The CLI wraps the same API:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
invoke search "latest MCP agent failures"
|
|
271
|
+
invoke workflow safe-tool-execution
|
|
272
|
+
invoke workflow live-context-retrieval --query "latest OpenAI MCP auth changes before deploying"
|
|
273
|
+
invoke workflow failure-trace-visualization
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The workflow response includes a buyer-readable `visual_flow` and a structured `trace`, for example:
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
request_received -> context_retrieved -> risk_scanned -> tool_authorized -> execution_completed
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### 7. Call a tool directly
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
curl -X POST "$INVOKE_BASE_URL/v1/call" \
|
|
286
|
+
-H "Content-Type: application/json" \
|
|
287
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
288
|
+
-d '{
|
|
289
|
+
"tool": "fetch.url",
|
|
290
|
+
"params": {"url": "https://github.com"},
|
|
291
|
+
"agent_id": "research_agent_v1"
|
|
292
|
+
}'
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Deploy on Render
|
|
296
|
+
|
|
297
|
+
This repo is ready to deploy to Render as a Docker web service.
|
|
298
|
+
|
|
299
|
+
### 1. Push this repo to GitHub
|
|
300
|
+
|
|
301
|
+
Render deploys from your Git branch, so make sure the latest backend code is pushed first.
|
|
302
|
+
|
|
303
|
+
### 2. Create the service from `render.yaml`
|
|
304
|
+
|
|
305
|
+
In Render, create a new Blueprint and point it at this repo. The checked-in [render.yaml](render.yaml) provisions:
|
|
306
|
+
|
|
307
|
+
- a Docker web service named `invoke-api`
|
|
308
|
+
- a health check at `/health`
|
|
309
|
+
- env vars for Invoke plus Supabase-backed persistence
|
|
310
|
+
|
|
311
|
+
### 3. Fill the required secrets
|
|
312
|
+
|
|
313
|
+
Render will prompt for:
|
|
314
|
+
|
|
315
|
+
- `INVOKE_API_KEYS`: comma-separated API keys allowed to call Invoke
|
|
316
|
+
- `INVOKE_PUBLIC_URL`: your deployed URL, such as `https://invoke-api.onrender.com`
|
|
317
|
+
- `INVOKE_ALLOWED_ORIGINS`: comma-separated frontend origins allowed to call the API, such as `https://invoke.vercel.app`
|
|
318
|
+
- `INVOKE_ALLOWED_ORIGIN_REGEX`: optional regex for preview deployments, such as `https://.*\\.vercel\\.app`
|
|
319
|
+
- `SUPABASE_URL`: your Supabase project URL, for example `https://xyzcompany.supabase.co` (the backend also tolerates a full `/rest/v1` URL if you already copied that)
|
|
320
|
+
- `SUPABASE_SERVICE_ROLE_KEY`: your Supabase service role key
|
|
321
|
+
- `LINEAR_API_KEY`: Linear API key for real issue creation
|
|
322
|
+
- `LINEAR_TEAM_ID`: optional default Linear team UUID for hosted demos
|
|
323
|
+
- `SLACK_BOT_TOKEN`: Slack bot token for real channel listing and message posting
|
|
324
|
+
- `SLACK_DEFAULT_CHANNEL`: optional default Slack channel ID for hosted demos
|
|
325
|
+
- `EXA_API_KEY`: Exa API key for `/search` and `invoke search`
|
|
326
|
+
|
|
327
|
+
### 4. Create the Supabase tables
|
|
328
|
+
|
|
329
|
+
Open the Supabase SQL editor and run [supabase/schema.sql](supabase/schema.sql).
|
|
330
|
+
|
|
331
|
+
That creates the tables Invoke uses for:
|
|
332
|
+
|
|
333
|
+
- connected providers
|
|
334
|
+
- provider keys
|
|
335
|
+
- dynamic tools
|
|
336
|
+
- pending approvals
|
|
337
|
+
- tool-call traces
|
|
338
|
+
- execution records
|
|
339
|
+
|
|
340
|
+
If Render logs `supabase_schema_missing` or a PostgREST `404` for
|
|
341
|
+
`invoke_providers`, the API is alive but persistence is not installed yet. Run
|
|
342
|
+
the schema file above in the same Supabase project used by `SUPABASE_URL`.
|
|
343
|
+
|
|
344
|
+
### 5. Verify the deployment
|
|
345
|
+
|
|
346
|
+
Health check:
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
curl https://YOUR-SERVICE.onrender.com/health
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Tool registry:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
curl https://YOUR-SERVICE.onrender.com/tools \
|
|
356
|
+
-H "X-API-Key: YOUR_API_KEY"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Notes
|
|
360
|
+
|
|
361
|
+
- If `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are set, Invoke stores runtime state in Supabase instead of local SQLite files.
|
|
362
|
+
- `TRACE_DB`, `TRACE_LOG_FILE`, and `TRACE_EVENTS_FILE` still exist as local fallbacks for development and emergency startup.
|
|
363
|
+
- Keep the Supabase service role key server-side only. Do not expose it in browser code or client SDKs.
|
|
364
|
+
- If a Vercel frontend calls the Render backend directly from the browser, set `TRACE_ALLOWED_ORIGINS` and, if you use preview deploys, `TRACE_ALLOWED_ORIGIN_REGEX`.
|
|
365
|
+
- For Slack, the bot token needs `chat:write` to send messages and `channels:read` / `groups:read` if you want the frontend to list channels.
|
|
366
|
+
|
|
367
|
+
### Vercel Frontend -> Render Backend
|
|
368
|
+
|
|
369
|
+
This repo does not include a Next.js or Vercel frontend yet, but the backend is ready for one.
|
|
370
|
+
|
|
371
|
+
Frontend env vars:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
NEXT_PUBLIC_INVOKE_BASE_URL=https://invoke-ai.onrender.com
|
|
375
|
+
NEXT_PUBLIC_INVOKE_API_KEY=YOUR_PUBLIC_DEMO_KEY
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Backend env vars on Render:
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
INVOKE_PUBLIC_URL=https://invoke-ai.onrender.com
|
|
382
|
+
INVOKE_ALLOWED_ORIGINS=https://your-frontend.vercel.app
|
|
383
|
+
INVOKE_ALLOWED_ORIGIN_REGEX=https://.*\\.vercel\\.app
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
For server-side calls from a Vercel route handler or server action, use the existing SDK env names instead:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
INVOKE_BASE_URL=https://invoke-ai.onrender.com
|
|
390
|
+
INVOKE_API_KEY=YOUR_SERVER_SIDE_KEY
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Build A Tool Wrapper
|
|
394
|
+
|
|
395
|
+
Create launch connector wrappers:
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
invoke wrap github
|
|
399
|
+
invoke wrap notion
|
|
400
|
+
invoke wrap linear
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Wrap a PostgreSQL query:
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
invoke wrap postgresql \
|
|
407
|
+
--query "SELECT * FROM invoices WHERE id = :invoice_id" \
|
|
408
|
+
--name "invoice lookup"
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Wrap an OpenAPI service:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
invoke wrap billing-api \
|
|
415
|
+
--openapi openapi.json \
|
|
416
|
+
--base-url https://billing.example.com
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
This creates a runnable MCP wrapper under `wrapped_tools/` with:
|
|
420
|
+
|
|
421
|
+
- capability metadata
|
|
422
|
+
- JSON schema validation
|
|
423
|
+
- structured JSON-RPC errors
|
|
424
|
+
- idempotency hints
|
|
425
|
+
- retry hints
|
|
426
|
+
- `invoke.register.json` for provider onboarding
|
|
427
|
+
|
|
428
|
+
The npm command is the recommended path. The underlying Python entrypoint remains available for local development:
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
python agentify.py wrap github
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Runtime API
|
|
435
|
+
|
|
436
|
+
### Connect Hosted MCP Gateway
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
from sdk import invoke
|
|
440
|
+
|
|
441
|
+
connected = invoke.connect(
|
|
442
|
+
"github",
|
|
443
|
+
owner_email="dev@acme.example",
|
|
444
|
+
approval_email="ops@acme.example",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
print(connected["gateway_url"])
|
|
448
|
+
print(connected["tools"][0]["key"])
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
The gateway returns a hosted endpoint such as `https://github.invoke.dev` plus an MCP URL and preloaded launch-tool metadata.
|
|
452
|
+
|
|
453
|
+
### TypeScript SDK
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
import { Invoke } from "./sdk";
|
|
457
|
+
|
|
458
|
+
const invoke = new Invoke({ apiKey: process.env.INVOKE_API_KEY! });
|
|
459
|
+
|
|
460
|
+
const result = await invoke.call({
|
|
461
|
+
tool: "fetch.url",
|
|
462
|
+
params: { url: "https://github.com" },
|
|
463
|
+
agentId: "research_agent_v1",
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const connected = await invoke.connect({
|
|
467
|
+
saas: "linear",
|
|
468
|
+
ownerEmail: "dev@acme.example",
|
|
469
|
+
});
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### HTTP
|
|
473
|
+
|
|
474
|
+
All runtime endpoints require `X-API-Key`.
|
|
475
|
+
|
|
476
|
+
Create and inspect a v1 execution:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
curl -X POST http://localhost:8000/v1/executions \
|
|
480
|
+
-H "Content-Type: application/json" \
|
|
481
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
482
|
+
-H "Idempotency-Key: checkout-retry-001" \
|
|
483
|
+
-d '{"workflow":"safe-tool-execution","input":{"params":{"invoice_id":"inv_123"}}}'
|
|
484
|
+
|
|
485
|
+
curl http://localhost:8000/v1/executions \
|
|
486
|
+
-H "X-API-Key: $INVOKE_API_KEY"
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
List registered tools:
|
|
490
|
+
|
|
491
|
+
```bash
|
|
492
|
+
curl http://localhost:8000/tools \
|
|
493
|
+
-H "X-API-Key: $INVOKE_API_KEY"
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Call a tool:
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
curl http://localhost:8000/v1/call \
|
|
500
|
+
-H "Content-Type: application/json" \
|
|
501
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
502
|
+
-d '{
|
|
503
|
+
"tool": "fetch.url",
|
|
504
|
+
"params": {"url": "https://github.com"},
|
|
505
|
+
"agent_id": "research_agent_v1"
|
|
506
|
+
}'
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Connect a launch SaaS and get its hosted gateway:
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
curl -X POST http://localhost:8000/connect/github \
|
|
513
|
+
-H "Content-Type: application/json" \
|
|
514
|
+
-H "X-API-Key: $INVOKE_API_KEY" \
|
|
515
|
+
-d '{"owner_email": "dev@acme.example"}'
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## State Revalidation Engine
|
|
519
|
+
|
|
520
|
+
Agents should act on current truth, not stale assumptions. Use `invoke.verify_state(...)` before execution when an action depends on critical business state:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
from sdk import invoke
|
|
524
|
+
|
|
525
|
+
state = invoke.verify_state(
|
|
526
|
+
intent="send_invoice_reminder",
|
|
527
|
+
required_fields=["invoice_status", "balance"],
|
|
528
|
+
assumed_state={"invoice_status": "unpaid", "balance": 125},
|
|
529
|
+
state_refetch={
|
|
530
|
+
"tool": "fetch.url",
|
|
531
|
+
"params": {"url": "https://billing.example.com/invoices/inv_123"},
|
|
532
|
+
},
|
|
533
|
+
conditions={
|
|
534
|
+
"invoice_status": "unpaid",
|
|
535
|
+
"balance": "> 0",
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if state["decision"] != "execute":
|
|
540
|
+
return "Invoice is no longer unpaid. Abort reminder."
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Invoke re-fetches current state, compares it with the decision-time assumptions, computes field-level drift, then returns:
|
|
544
|
+
|
|
545
|
+
- `verified` / `execute`: required fields still match and conditions pass.
|
|
546
|
+
- `blocked` / `abort`: state is missing, changed, or no longer satisfies conditions.
|
|
547
|
+
- `replan_required` / `replan`: same mismatch path when `on_mismatch="replan"`.
|
|
548
|
+
|
|
549
|
+
## Entity Resolution Tracking
|
|
550
|
+
|
|
551
|
+
Agents should act on the correct entity, not a guessed ID. Attach `entity_resolution` to a tool call when the agent resolved a customer, account, invoice, or user before acting:
|
|
552
|
+
|
|
553
|
+
```python
|
|
554
|
+
from sdk import invoke
|
|
555
|
+
|
|
556
|
+
result = invoke.call(
|
|
557
|
+
tool="billing.send_reminder",
|
|
558
|
+
params={"customer_id": "cust_123", "invoice_id": "inv_456"},
|
|
559
|
+
agent_id="billing_agent",
|
|
560
|
+
entity_resolution={
|
|
561
|
+
"entity_id": "cust_123",
|
|
562
|
+
"source": "crm_lookup",
|
|
563
|
+
"resolved_at": "2026-05-01T12:00:00Z",
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Invoke logs the resolved `entity_id`, source, and timestamp into the tool-call trace. Before execution, it compares that entity against IDs in the action params and execution state. If the action points at a different entity, Invoke blocks the call with `409` and never touches the tool.
|
|
569
|
+
|
|
570
|
+
The same check runs again when a pending approval is approved. If the thawed state now points at a different customer or account, the approval is blocked instead of executing stale work.
|
|
571
|
+
|
|
572
|
+
## Failure Policy Engine
|
|
573
|
+
|
|
574
|
+
Agents should not guess what to do when tools fail. Add a failure policy to a tool call to make retry, fallback, and escalation behavior explicit and bounded:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{
|
|
578
|
+
"retry": 2,
|
|
579
|
+
"fallback": "secondary_api",
|
|
580
|
+
"on_failure": "escalate"
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
```python
|
|
585
|
+
from sdk import invoke
|
|
586
|
+
|
|
587
|
+
result = invoke.call(
|
|
588
|
+
tool="billing.primary_lookup",
|
|
589
|
+
params={"invoice_id": "inv_123"},
|
|
590
|
+
agent_id="billing_agent",
|
|
591
|
+
failure_policy={
|
|
592
|
+
"retry": 2,
|
|
593
|
+
"fallback": "billing.secondary_lookup",
|
|
594
|
+
"on_failure": "escalate",
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Invoke enforces a hard retry cap. `retry: 2` means one initial attempt plus two bounded retries, never an infinite loop. If the primary tool still fails, Invoke can call the fallback tool once. If everything fails and `on_failure` is `escalate`, Invoke creates a pending approval with the failure context for a human to review.
|
|
600
|
+
|
|
601
|
+
## Outcome Reconciliation
|
|
602
|
+
|
|
603
|
+
Never retry blindly when the outcome is unknown. If a timeout or partial failure happens after a side effect may have occurred, reconcile the action first:
|
|
604
|
+
|
|
605
|
+
```python
|
|
606
|
+
from sdk import invoke
|
|
607
|
+
|
|
608
|
+
outcome = invoke.reconcile({
|
|
609
|
+
"action": {
|
|
610
|
+
"intent": "charge_customer",
|
|
611
|
+
"params": {"payment_id": "pay_123"},
|
|
612
|
+
},
|
|
613
|
+
"outcome": "UNKNOWN",
|
|
614
|
+
"state_refetch": {
|
|
615
|
+
"tool": "payments.lookup",
|
|
616
|
+
"params": {"payment_id": "pay_123"},
|
|
617
|
+
},
|
|
618
|
+
"conditions": {"charged": True},
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
if outcome["decision"] == "do_not_retry":
|
|
622
|
+
return "Payment already succeeded. Do not charge again."
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
You can also attach reconciliation to a failure policy:
|
|
626
|
+
|
|
627
|
+
```json
|
|
628
|
+
{
|
|
629
|
+
"retry": 2,
|
|
630
|
+
"on_failure": "escalate",
|
|
631
|
+
"reconcile": {
|
|
632
|
+
"action": "charge_customer",
|
|
633
|
+
"state_refetch": {
|
|
634
|
+
"tool": "payments.lookup",
|
|
635
|
+
"params": {"payment_id": "pay_123"}
|
|
636
|
+
},
|
|
637
|
+
"conditions": {"charged": true}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
When a failure has an `UNKNOWN` outcome, Invoke runs reconciliation before retrying. If reconciliation shows the action already succeeded, Invoke returns `outcome_reconciled` and blocks duplicate retries. If reconciliation shows it did not succeed, bounded retry can continue. If the outcome is still unknown, Invoke escalates or errors according to policy.
|
|
643
|
+
|
|
644
|
+
## Approval Gates
|
|
645
|
+
|
|
646
|
+
Approval checkpoints carry an execution snapshot: params, variables, tool outputs, action, policy contract, and timestamps. Approval revalidates policy against fresh state before execution and returns `executed`, `cancelled`, `replan_required`, or `requeued`.
|
|
647
|
+
|
|
648
|
+
### Conditional Approval Contracts
|
|
649
|
+
|
|
650
|
+
Use a conditional approval contract when an approval is only valid if live business state still matches what the human approved. A contract with `intent` and `conditions` creates a pending approval even if the underlying tool is normally low-risk:
|
|
651
|
+
|
|
652
|
+
```json
|
|
653
|
+
{
|
|
654
|
+
"intent": "send_invoice_reminder",
|
|
655
|
+
"conditions": {
|
|
656
|
+
"invoice_status": "overdue",
|
|
657
|
+
"customer_balance": "> 0"
|
|
658
|
+
},
|
|
659
|
+
"threshold": "strict",
|
|
660
|
+
"expires_at": "2026-04-30T15:00:00Z"
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
When approval happens, Invoke fetches or accepts current state, compares it with the frozen approval-time state, and computes drift for each condition key.
|
|
665
|
+
|
|
666
|
+
- Valid: execute the original action.
|
|
667
|
+
- Changed: mark the old approval `invalidated` and create a fresh pending approval with the live state.
|
|
668
|
+
- Expired: cancel before execution.
|
|
669
|
+
|
|
670
|
+
`threshold: "strict"` means condition values must still match the approval-time snapshot exactly and the current values must still satisfy every condition. `threshold: "conditions"` allows value changes as long as the current values still satisfy the conditions.
|
|
671
|
+
|
|
672
|
+
```python
|
|
673
|
+
from sdk import invoke
|
|
674
|
+
|
|
675
|
+
policy = {
|
|
676
|
+
"rules": [
|
|
677
|
+
{
|
|
678
|
+
"when": "action == git_push and branch == main",
|
|
679
|
+
"effect": "require_approval",
|
|
680
|
+
"intent": "push_to_main",
|
|
681
|
+
"allowed_action": "git_push",
|
|
682
|
+
"reason": "Human approval required for pushes to main",
|
|
683
|
+
}
|
|
684
|
+
]
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
pending = invoke.call(
|
|
688
|
+
tool="fetch.url",
|
|
689
|
+
params={"url": "https://example.com/repo", "branch": "main"},
|
|
690
|
+
agent_id="dev_agent_v1",
|
|
691
|
+
action="git_push",
|
|
692
|
+
policy=policy,
|
|
693
|
+
execution_state={
|
|
694
|
+
"variables": {"branch": "main"},
|
|
695
|
+
"tool_outputs": {"diff_summary": {"files_changed": 3}},
|
|
696
|
+
},
|
|
697
|
+
)
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
A domain policy can freeze intent-specific work:
|
|
701
|
+
|
|
702
|
+
```json
|
|
703
|
+
{
|
|
704
|
+
"intent": "send_invoice_reminder",
|
|
705
|
+
"condition": "invoice_status == overdue",
|
|
706
|
+
"allowed_action": "send_email",
|
|
707
|
+
"expires_at": "2026-04-30T15:00:00Z"
|
|
708
|
+
}
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
When the human approves, Invoke can accept fresh state from the approver or run a configured `state_refetch` read tool. It then thaws the checkpoint, validates `condition`, checks `allowed_action` and `expires_at`, and decides whether to execute, cancel, or ask the agent to re-plan.
|
|
712
|
+
|
|
713
|
+
Open the approval dashboard:
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
open "http://localhost:8000/dashboard/approvals?api_key=$INVOKE_API_KEY"
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
For approval notifications, set:
|
|
720
|
+
|
|
721
|
+
```bash
|
|
722
|
+
export APPROVAL_SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."
|
|
723
|
+
export APPROVAL_EMAIL_WEBHOOK_URL="https://email-webhook.example/send"
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
## CLI Commands
|
|
727
|
+
|
|
728
|
+
The CLI gives you the first Invoke project lifecycle:
|
|
729
|
+
|
|
730
|
+
```bash
|
|
731
|
+
invoke login --base-url https://agentgate-ai.onrender.com --api-key inv_live_...
|
|
732
|
+
invoke init support-agent --template crm-guardrail
|
|
733
|
+
invoke dev
|
|
734
|
+
invoke deploy
|
|
735
|
+
invoke call <tool> '{"json":"params"}'
|
|
736
|
+
invoke agents list
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
Wrapper generation is still available for production tools:
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
invoke wrap github
|
|
743
|
+
invoke wrap notion
|
|
744
|
+
invoke wrap linear
|
|
745
|
+
invoke wrap postgresql --query "SELECT * FROM users WHERE id = :user_id"
|
|
746
|
+
invoke wrap billing-api --openapi openapi.json --base-url http://localhost:8000
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Credentials from `invoke login` are stored in `~/.invoke/credentials.json`. `invoke dev` writes the local MCP URL to `.invoke/dev.json`, and `invoke deploy` uses it automatically when `invoke.json` still has the placeholder MCP URL. Set `INVOKE_HOME` to override the global credentials location in CI.
|
|
750
|
+
|
|
751
|
+
## Configuration
|
|
752
|
+
|
|
753
|
+
Local server environment variables:
|
|
754
|
+
|
|
755
|
+
| Variable | Description |
|
|
756
|
+
| --- | --- |
|
|
757
|
+
| `INVOKE_API_KEYS` | Comma-separated full-access server API keys |
|
|
758
|
+
| `TRACE_API_KEYS` | Existing Trace-compatible server API key env var |
|
|
759
|
+
| `INVOKE_API_KEY_SCOPES` | JSON scoped-token config for tool allowlists and scopes |
|
|
760
|
+
| `TRACE_API_KEY_SCOPES` | Existing Trace-compatible scoped-token env var |
|
|
761
|
+
| `INVOKE_LOG_FILE` | Override JSON audit log filename |
|
|
762
|
+
| `TRACE_LOG_FILE` | Existing Trace-compatible audit log filename |
|
|
763
|
+
| `INVOKE_ALLOWED_ORIGINS` | Comma-separated browser origins allowed by CORS |
|
|
764
|
+
| `TRACE_ALLOWED_ORIGINS` | Existing Trace-compatible CORS allowlist env var |
|
|
765
|
+
| `INVOKE_ALLOWED_ORIGIN_REGEX` | Optional regex for preview frontend origins such as Vercel previews |
|
|
766
|
+
| `TRACE_ALLOWED_ORIGIN_REGEX` | Existing Trace-compatible preview-origin regex env var |
|
|
767
|
+
| `SUPABASE_URL` | Optional Supabase project URL for hosted persistence; `/rest/v1` suffix is tolerated |
|
|
768
|
+
| `SUPABASE_SERVICE_ROLE_KEY` | Optional Supabase service role key for hosted persistence |
|
|
769
|
+
| `SUPABASE_TABLE_PREFIX` | Optional Supabase table prefix, default `invoke_` |
|
|
770
|
+
| `LINEAR_API_KEY` | Optional Linear API key for direct issue creation |
|
|
771
|
+
| `LINEAR_TEAM_ID` | Optional default Linear team UUID for hosted demos |
|
|
772
|
+
| `SLACK_BOT_TOKEN` | Optional Slack bot token for direct channel listing and posting |
|
|
773
|
+
| `SLACK_DEFAULT_CHANNEL` | Optional default Slack channel ID for hosted demos |
|
|
774
|
+
| `FAILURE_POLICY_MAX_RETRIES` | Hard cap for per-call failure-policy retries, default `5` |
|
|
775
|
+
| `HOST` | Server bind host, default `0.0.0.0` |
|
|
776
|
+
| `PORT` | Server bind port, default `8000` |
|
|
777
|
+
|
|
778
|
+
Client environment variables:
|
|
779
|
+
|
|
780
|
+
| Variable | Description |
|
|
781
|
+
| --- | --- |
|
|
782
|
+
| `INVOKE_API_KEY` | API key used by the Python and TypeScript SDKs |
|
|
783
|
+
| `TRACE_API_KEY` | Existing Trace-compatible client API key env var |
|
|
784
|
+
| `INVOKE_BASE_URL` | SDK base URL override |
|
|
785
|
+
| `TRACE_BASE_URL` | Existing Trace-compatible SDK base URL override |
|
|
786
|
+
|
|
787
|
+
Scoped token example:
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
export INVOKE_API_KEY_SCOPES='{
|
|
791
|
+
"inv_scoped_fetch_read": {
|
|
792
|
+
"scopes": ["tools:read", "tools:call", "traces:read"],
|
|
793
|
+
"allowed_tools": ["fetch.url"],
|
|
794
|
+
"read_only": true,
|
|
795
|
+
"agent_id": "research_agent_v1",
|
|
796
|
+
"envs": ["dev", "prod"],
|
|
797
|
+
"agents": ["research_agent_v1"],
|
|
798
|
+
"workflows": ["market_research"],
|
|
799
|
+
"resources": ["cust_123", "repo:acme/app"]
|
|
800
|
+
}
|
|
801
|
+
}'
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Supported scopes today: `tools:read`, `tools:call`, `state:verify`, `outcomes:reconcile`, `approvals:read`, `approvals:write`, `logs:read`, `traces:read`, and `providers:admin`.
|
|
805
|
+
|
|
806
|
+
`allowed_tools` and `read_only` gate tool access. `envs`, `agents`, `workflows`, `allowed_actions`, and `resources` gate execution context, so a token can mean "this agent may call this tool in prod for this workflow and resource" instead of only re-exposing provider OAuth scopes.
|
|
807
|
+
|
|
808
|
+
## Observability
|
|
809
|
+
|
|
810
|
+
List recent tool-call traces:
|
|
811
|
+
|
|
812
|
+
```bash
|
|
813
|
+
curl http://localhost:8000/traces \
|
|
814
|
+
-H "X-API-Key: $INVOKE_API_KEY"
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Export traces:
|
|
818
|
+
|
|
819
|
+
```bash
|
|
820
|
+
curl "http://localhost:8000/traces/export?format=langsmith" \
|
|
821
|
+
-H "X-API-Key: $INVOKE_API_KEY"
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
Local logs:
|
|
825
|
+
|
|
826
|
+
- JSON audit logs: `logs/trace.log`
|
|
827
|
+
- tool-call traces: `logs/tool_calls.jsonl`
|
|
828
|
+
- trace export formats: JSON, JSONL, LangSmith-shaped, and Helicone-shaped records
|
|
829
|
+
|
|
830
|
+
When Supabase persistence is enabled, `/traces` and `/traces/export` read from Supabase instead of the local JSONL file.
|
|
831
|
+
|
|
832
|
+
## Blast-Radius Demo
|
|
833
|
+
|
|
834
|
+
There is a demo harness that starts mock tools and runs a production-failure story. It shows how Invoke contains the blast radius when agent execution gets messy:
|
|
835
|
+
|
|
836
|
+
- tool timeout / transient upstream failure
|
|
837
|
+
- partial success with unknown outcome
|
|
838
|
+
- wrong CRM update blocked by entity resolution
|
|
839
|
+
- duplicated retry
|
|
840
|
+
- stale approval
|
|
841
|
+
- webhook inconsistency
|
|
842
|
+
|
|
843
|
+
```bash
|
|
844
|
+
FLAKY_FAIL_FIRST_N=2 ./demo/run_demo.sh
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
The demo starts the flaky MCP simulator, a mock tool/CRM MCP server, the Invoke gateway, then runs `demo_comparison.py` and cleans up processes. The buyer takeaway is simple: Invoke does not make agents smarter; it makes their execution bounded when production gets messy.
|
|
848
|
+
|
|
849
|
+
## What Exists Today
|
|
850
|
+
|
|
851
|
+
- API-key authentication
|
|
852
|
+
- scoped API tokens with tool allowlists and read-only checks
|
|
853
|
+
- npm/npx CLI package with `invoke login`, `invoke init`, `invoke deploy`, `invoke call`, `invoke agents list`, and `invoke wrap`
|
|
854
|
+
- `invoke wrap` generator for OpenAPI, GitHub, Notion, Linear, and PostgreSQL MCP wrappers
|
|
855
|
+
- hosted gateway URL metadata for connected SaaS tools
|
|
856
|
+
- agent-readable tool registry
|
|
857
|
+
- `/tools` capability cards
|
|
858
|
+
- `/discover` capability search
|
|
859
|
+
- provider onboarding with registered tools available in discovery and calls
|
|
860
|
+
- `/call` reliable tool invocation
|
|
861
|
+
- `/state/verify` state revalidation before execution
|
|
862
|
+
- entity resolution tracking with pre-execution mismatch blocking
|
|
863
|
+
- failure policy engine for bounded retry, fallback, and escalation
|
|
864
|
+
- outcome reconciliation to prevent duplicate retries after unknown results
|
|
865
|
+
- policy-as-code `pending_approval` responses
|
|
866
|
+
- conditional approval contracts with drift-based requeue
|
|
867
|
+
- frozen execution checkpoints with variables and tool outputs
|
|
868
|
+
- `/approvals` approval queue
|
|
869
|
+
- `/dashboard/approvals` web dashboard for human review
|
|
870
|
+
- Slack and email-webhook approval notifications
|
|
871
|
+
- approve/reject plus thaw-time execute, cancel, or re-plan decisions
|
|
872
|
+
- MCP Streamable HTTP support
|
|
873
|
+
- direct HTTP fallback for `fetch.url`
|
|
874
|
+
- JSON audit logs in `logs/trace.log`
|
|
875
|
+
- tool-call traces in `logs/tool_calls.jsonl`
|
|
876
|
+
- `/traces/export` for JSON, JSONL, LangSmith-shaped, and Helicone-shaped records
|
|
877
|
+
|
|
878
|
+
## Direction
|
|
879
|
+
|
|
880
|
+
Invoke is moving toward the runtime layer for real-world agent capabilities:
|
|
881
|
+
|
|
882
|
+
- scoped agent identities
|
|
883
|
+
- approval gates for risky actions
|
|
884
|
+
- provider onboarding and wrapper templates
|
|
885
|
+
- richer capability search
|
|
886
|
+
- usage metering and policy controls
|
|
887
|
+
- dashboard-grade observability
|