@arcforgelabs/flow 2026.11.2 → 2026.11.4

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.
Files changed (49) hide show
  1. package/SECURITY.md +12 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +119 -3
  6. package/server/app.js +3 -2
  7. package/server/auth/routes.js +20 -0
  8. package/server/auth/session.js +44 -1
  9. package/server/auth/settings.js +27 -0
  10. package/server/core/alerts.js +38 -3
  11. package/server/core/automations.js +8 -3
  12. package/server/core/invoice-rules.js +158 -8
  13. package/server/core/invoices.js +9 -1
  14. package/server/core/invoicing-rules.js +31 -6
  15. package/server/core/job-board.js +16 -1
  16. package/server/core/job-match.js +6 -4
  17. package/server/core/ledger.js +67 -8
  18. package/server/core/pipeline.js +6 -2
  19. package/server/core/reconcile.js +11 -3
  20. package/server/core/sending.js +30 -3
  21. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  22. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  23. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  24. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +21 -0
  27. package/server/integrations/seazona/feed.js +10 -3
  28. package/server/integrations/seazona/index.js +6 -1
  29. package/server/integrations/seazona/routes.js +65 -2
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/xero/drafts.js +1 -1
  32. package/server/integrations/xero/index.js +1 -1
  33. package/server/integrations/xero/ledger.js +17 -8
  34. package/server/integrations/xero/links.js +39 -1
  35. package/server/integrations/xero/lookup.js +3 -1
  36. package/server/integrations/xero/routes.js +18 -1
  37. package/server/ui/app-page.js +1 -1
  38. package/server/ui/app.js +7 -4
  39. package/server/ui/flow.css +69 -1
  40. package/server/ui/home.js +120 -0
  41. package/server/ui/invoice-rules.js +11 -3
  42. package/server/ui/invoicing.js +81 -6
  43. package/server/ui/job-board.js +8 -3
  44. package/server/ui/jobs.js +14 -2
  45. package/server/ui/nav-pins.js +5 -3
  46. package/server/ui/reconcile.js +4 -2
  47. package/server/ui/routes.js +1 -1
  48. package/skills/flow/SKILL.md +306 -0
  49. package/skills/flow/flow.sh +121 -0
@@ -0,0 +1,306 @@
1
+ ---
2
+ name: flow
3
+ description: Run the lab's Flow app and read Seazona through it - invoice tabs, send ready invoices, void or delete drafts, invoice rules, jobs, reconcile Seazona jobs with Flow and Xero, compare prices across Flow, Xero and Seazona, link price codes, review items, the Seazona history import, and Seazona orders, invoices and clients.
4
+ metadata: { "openclaw": { "requires": { "bins": ["curl", "openssl", "sha256sum"] } } }
5
+ ---
6
+
7
+ # Flow
8
+
9
+ Flow is the lab's invoicing app. It takes jobs from Seazona (the lab's case
10
+ system) and sends invoices to Xero. Use `{baseDir}/flow.sh` for every call.
11
+ It holds the Flow key; never print the key file or put the key in a message.
12
+
13
+ Flow address: saved by `setup` next to the key (or `FLOW_URL`). There is no
14
+ default: the key goes only to this lab's own Flow.
15
+ Key file: `FLOW_AGENT_KEY_FILE` (default `~/.openclaw/credentials/flow-agent-key`).
16
+
17
+ ## First time only
18
+
19
+ Run `{baseDir}/flow.sh setup https://<this lab's Flow>`. It saves the address,
20
+ makes the key once and prints only `FLOW_AGENT_KEY_SHA256=<64 hex>`. That digest is safe to share: give it to the
21
+ person who sets up Flow. Flow stores the digest, never the key. Run `setup`
22
+ again any time to see the same digest. To change the key, delete the key file,
23
+ run `setup`, and give the new digest to Flow.
24
+
25
+ Then check: `{baseDir}/flow.sh get me` must answer `HTTP 200`.
26
+
27
+ ## Keep the skill current
28
+
29
+ Run `{baseDir}/flow.sh update` after Flow gets a new release, and when a call
30
+ answers 404 for a route this skill lists. It gets this skill from the Flow it
31
+ talks to, so the skill always matches the release that Flow runs. It changes
32
+ nothing if the answer is not a good skill. Install the skill from GitHub only
33
+ the first time.
34
+
35
+ ## Calls
36
+
37
+ Every answer ends with a line `HTTP <code>`. 401 or 403: the key is not set up
38
+ in Flow, or the Flow person is turned off. Stop and tell the person.
39
+
40
+ Read first. Before a change, read the record, say what you will change, and
41
+ for sends, voids and deletes get the person's yes in the chat.
42
+
43
+ | Do this | Command |
44
+ | --- | --- |
45
+ | Tabs and counts | `flow.sh get 'invoicing/invoices?status=ready&limit=50'` (status: pending, ready, draft, approved, sent, non_chargeable, void) |
46
+ | Search invoices | `flow.sh get 'invoicing/invoices?q=<text>'` |
47
+ | One invoice | `flow.sh get invoicing/invoices/<id>` |
48
+ | Mark ready | `flow.sh post invoicing/invoices/mark-ready '{"invoiceIds":["<id>"]}'` |
49
+ | Send all ready to Xero | `flow.sh post invoicing/invoices/send-ready '{}'` (or `'{"invoiceIds":["<id>"]}'`) |
50
+ | Void an invoice | `flow.sh post invoicing/invoices/<id>/void` |
51
+ | Delete a draft | `flow.sh delete invoicing/invoices/<id>` |
52
+ | Jobs | `flow.sh get 'invoicing/jobs?limit=50'`, one job `invoicing/jobs/<id>/activity` |
53
+ | Invoice rules | `flow.sh get invoicing/rules`; what a rule can use: `flow.sh get invoicing/rule-fields` |
54
+ | Add a rule | `flow.sh post invoicing/rules '{"name":"...","conditions":[{"field":"job_total","test":"at_most","value":0}],"action":{"kind":"wait"}}'` |
55
+ | Change or turn off a rule | `flow.sh put invoicing/rules/<id> '{"enabled":false}'` |
56
+ | Delete a rule | `flow.sh delete invoicing/rules/<id>` |
57
+ | Rule that offers an item (shipping) | `flow.sh post invoicing/rules '{"name":"Shipping","conditions":[{"field":"line_item","test":"has_not","value":"S/H"}],"action":{"kind":"pending","note":"Add shipping, or skip with a reason.","addItem":"S/H"}}'` |
58
+ | Add the rule's item to a draft | `flow.sh post invoicing/invoices/<id>/rules/<ruleId>/add-item '{}'` |
59
+ | Skip a rule for one job | on its draft: `flow.sh post invoicing/invoices/<id>/rules/<ruleId>/exception '{"reason":"..."}'`; no draft yet: `flow.sh post invoicing/jobs/<jobId>/rules/<ruleId>/exception '{"reason":"..."}'` |
60
+ | Jobs the work system must fix | `flow.sh get 'invoicing/jobs?limit=200'`: each job's `sourceNeeds` lists the items a rule wants that the work system's job lacks |
61
+ | A rule's exceptions | `flow.sh get invoicing/rules/<id>/exceptions`; take one back: `flow.sh delete invoicing/rules/<id>/exceptions/<exceptionId>` |
62
+ | Automations | `flow.sh get invoicing/automations` |
63
+ | Flow settings | `flow.sh get invoicing/settings` |
64
+
65
+ Rule actions: `wait` (hold the job), `skip` (never invoice it), `pending`
66
+ (draft it, but hold it on Pending with a note) or `non_chargeable`. Take
67
+ field, test and value only from `rule-fields`. A pending rule with `addItem`
68
+ offers that item. Add it or skip it only when the person says so, and give
69
+ the person's reason for a skip. An exception ends when the job's lines change
70
+ (`current: false` in the rule's exceptions list): ask the person again. The work system must get the item too: tell
71
+ the person which jobs show "needs <item>" on the job board.
72
+
73
+ Voids: the Void tab holds invoices voided in Flow **or voided or deleted in
74
+ Xero**. Before a clean-up, list the tab, group the rows (Flow void, Xero void,
75
+ deleted draft), show the person the groups, and act only on the group they
76
+ confirm.
77
+
78
+ Sending: Flow sends each invoice to Xero as a draft unless the "Approve and
79
+ email" setting is on (`invoicing/settings`). Say which one applies before a send.
80
+
81
+ ## Share links
82
+
83
+ When you name an invoice, a job or a contact to a person, give its Flow link.
84
+ Never search GitHub or guess a path: `flow.sh link` prints the address.
85
+
86
+ | Link to | Command | Id to use |
87
+ | --- | --- | --- |
88
+ | An invoice | `flow.sh link invoice <id>` | the invoice's `id`, or a job's `invoiceId`. Never the invoice number |
89
+ | A job's Reconcile page | `flow.sh link reconcile <id>` | a job's `id` from `invoicing/jobs` only (never an invoice id) |
90
+ | A job on the Jobs board | `flow.sh link job <case number>` | the job's `caseRef`; if it is empty, its `sourceJobRef` |
91
+ | A contact | `flow.sh link contact <id>` | the contact's `id` |
92
+
93
+ For a job that has an `invoiceId`, give the invoice link: it opens that one
94
+ record. The Jobs link is a search, so it can also show other jobs whose text
95
+ holds the same number. Use it only for a job with no invoice.
96
+
97
+ Paste the command's output as a Markdown link with a short label, one per line.
98
+ For example, the commands print addresses like these:
99
+
100
+ ```
101
+ - [INV-0123](https://flow.example.com/invoices/3f6c2a1e-8b4d-4c2f-9a7e-1d2b3c4d5e6f) $242.00, Pending: add shipping
102
+ - [Case 3716](https://flow.example.com/jobs?q=3716) shipped, no invoice yet
103
+ ```
104
+
105
+ Never type or change an address yourself. If `flow.sh link` says the id is not
106
+ a UUID, read the record first (`flow.sh get 'invoicing/invoices?q=<number>'`)
107
+ and use its `id`.
108
+
109
+ ## Prices and price codes
110
+
111
+ Flow's price list is the lab's own list of codes and prices
112
+ (`docs/PRICE_LIST.md`). Flow keeps a copy of the Xero items and the Seazona
113
+ products and links each one to a Flow code. `price-list` reads only Flow's
114
+ copies, so it costs no Xero or Seazona calls.
115
+
116
+ | Do this | Command |
117
+ | --- | --- |
118
+ | Compare all prices | `flow.sh get invoicing/price-list` |
119
+ | Flow codes only | `flow.sh get 'invoicing/price-items?q=<text>'` |
120
+ | Change a Flow price | `flow.sh put invoicing/price-items/<id> '{"unitAmountCents":13500}'` (also `name`, `taxCode` GST or GST_FREE) |
121
+ | Add a Flow code | `flow.sh post invoicing/price-items '{"code":"...","name":"...","unitAmountCents":13500,"taxCode":"GST"}'` |
122
+ | Link records to codes | `flow.sh post invoicing/price-links '{"links":[{"priceItemId":"<id>","system":"xero","externalId":"<id>"}]}'` |
123
+ | Remove a link | `flow.sh delete invoicing/price-links/<link id>` |
124
+ | Accept a difference | `flow.sh post invoicing/price-items/<id>/resolve` |
125
+ | Lines that wait for a code | `flow.sh get invoicing/line-labels` (rows with `unmapped` above 0) |
126
+ | Give a line its code | `flow.sh post invoicing/line-mappings '{"kind":"saved","label":"<label>","unitAmountCents":<label price>,"itemCode":"<Flow code>"}'` |
127
+ | Saved code choices | `flow.sh get invoicing/line-mappings`; change `put invoicing/line-mappings/<id>`, remove `delete invoicing/line-mappings/<id>` |
128
+
129
+ How to read `price-list`:
130
+
131
+ - `systems`: each connected list (`xero`, `seazona`), its record count and
132
+ `syncedAt`. An old `syncedAt` means Flow's copy can be behind.
133
+ - `items`: each Flow code with its `links` (the linked Xero item or Seazona
134
+ product, as `record`) and `findings`. A finding `kind` is `price` (Flow and
135
+ the other system differ: `flow` and `other` in cents), `tax`, `missing` (the
136
+ linked record is gone) or `not_linked` (no Xero item for this code).
137
+ - `unlinked`: Xero items and Seazona products that no Flow code has yet.
138
+
139
+ Reconcile steps:
140
+
141
+ 1. Read `price-list` and `line-labels`. Show the person one table: each code
142
+ with the Flow, Xero and Seazona price, what differs, and each line label
143
+ that waits.
144
+ 2. For each difference, ask which price is right. Do not guess.
145
+ 3. When the person says yes, fix it in the right place:
146
+ - Flow price or code: the Flow commands above.
147
+ - Xero item: your own `xero` command (Flow never changes a Xero item).
148
+ Then refresh Flow's copy of Xero items and contacts:
149
+ `flow.sh post xero/sync '{"organisationId":"<org>"}'`.
150
+ - Seazona product: read only. Tell the person to change it in Seazona.
151
+ - A record that is the same thing as a code but has no link: link it.
152
+ - A difference that is correct (for example a Seazona product at $0):
153
+ accept it with `resolve`.
154
+ 4. Waiting lines: match each label to a code by name and price, show the
155
+ person the pairs, and save each pair they agree with as a line mapping.
156
+ The `label` and `unitAmountCents` come from that `line-labels` row. Jobs
157
+ with that line move on at the next Flow sync (a few minutes).
158
+ 5. Read `price-list` and `line-labels` again and show what is left.
159
+
160
+ Every change on this list (price, code, link, resolve, line mapping) needs
161
+ the person's yes in the chat first, like a send.
162
+
163
+ ## Seazona (read only)
164
+
165
+ Flow reads Seazona for you with its own Seazona connection, so you need no
166
+ Seazona password. Calls share Seazona's limit of 30 a minute with Flow's
167
+ sync: make few calls, and never loop over many records.
168
+
169
+ | Do this | Command |
170
+ | --- | --- |
171
+ | Orders on one day | `flow.sh get 'seazona/api/orders/?ordered=2026-11-11T00:00:00&page=1&pageSize=50'` |
172
+ | One order | `flow.sh get seazona/api/orders/<order id>` |
173
+ | Invoices changed since | `flow.sh get 'seazona/api/invoices/?lastModified=2026-11-01T00:00:00'` |
174
+ | Clients changed since | `flow.sh get 'seazona/api/clients/?lastModified=2026-11-01T00:00:00'` |
175
+ | Products, users | `flow.sh get seazona/api/products/`, `flow.sh get seazona/api/users/` |
176
+
177
+ Answers are `{ "ok": true, "data": ... }`, as Seazona gives them. If the owner
178
+ turned on "Hide patient details", patient, note, file and clinical fields are
179
+ left out. Do not copy patient names outside this chat. 429: wait a
180
+ minute and try once more.
181
+
182
+ ## Fix a problem Flow shows
183
+
184
+ Flow's message is a flag, not the answer. Flow keeps three systems in line:
185
+ Seazona (the jobs), Flow (its invoices) and Xero (the invoices and payments).
186
+ No one system is always right. Find out what is true, then fix the wrong one.
187
+
188
+ 1. Read all three, through Flow:
189
+ - Flow: `flow.sh get invoicing/invoices/<id>` and
190
+ `flow.sh get invoicing/jobs/<job id>/reconcile`.
191
+ - Seazona: `flow.sh get seazona/api/orders/<order id>` (the job's
192
+ `sourceJobRef`, also its case number). Its invoice number is the job's
193
+ `sourceInvoiceRef`.
194
+ - Xero: `flow.sh post xero/invoices/status '{"organisationId":"<org>","invoiceIds":["<Flow invoice id>"]}'`.
195
+ 2. If they do not explain it, look deeper in the related records: history
196
+ import batches, older Seazona invoices, earlier Xero invoices for the same
197
+ client and reference, voids and credit notes. The cause is often one step
198
+ away from the job.
199
+ 3. Say what is true and show the evidence. If the evidence does not decide it,
200
+ ask the person. A field in one system can be wrong while the invoice is
201
+ right (for example a Seazona subtotal of 0 with a total that matches the
202
+ lines): say so, and do not change Flow to hide it.
203
+ 4. Fix the wrong system. Flow cannot write to Seazona: give the person the
204
+ exact fix and its link. Then read all three again to show they agree.
205
+
206
+ Ask the person before any send, void or tax change.
207
+
208
+ ## Reconcile: Seazona jobs, Flow invoices, Xero
209
+
210
+ When the person says "reconcile", "tidy up" or "why is this job not
211
+ invoiced", work in this order. Read first, show the person what you found in
212
+ short groups, then act on the group they confirm.
213
+
214
+ 1. **Jobs against Flow invoices.** `flow.sh get invoicing/reconcile` lists
215
+ jobs with no invoice and invoices with no job. For one row:
216
+ `flow.sh get invoicing/jobs/<job id>/reconcile` or
217
+ `flow.sh get invoicing/invoices/<id>/reconcile` (each lists the likely other
218
+ side; `best: true` is the top guess).
219
+ - Link a job to its invoice: `flow.sh post invoicing/jobs/<job id>/link '{"invoiceId":"<id>"}'`
220
+ - Job needs no invoice: `flow.sh post invoicing/jobs/<job id>/no-invoice`
221
+ - Make the invoice now: `flow.sh post invoicing/jobs/<job id>/draft`
222
+ - Read the job again from Seazona: `flow.sh post invoicing/jobs/<job id>/recheck`
223
+ - **Stuck on a void invoice** (the job shows "Invoice voided", or its Flow
224
+ invoice is void, and nothing drafts): `flow.sh post invoicing/jobs/<job id>/free '{"reason":"<why, in the person's words>"}'`.
225
+ The job lets go of the void invoice and goes on hold, so it does not
226
+ draft by itself. It refuses a live Flow invoice: void that first, only
227
+ after the person says yes to that invoice
228
+ (`flow.sh post invoicing/invoices/<id>/void`).
229
+ - **Xero already bills the job** (Send stopped it: "same client and
230
+ reference", or its Flow copy was voided for that): after the person says
231
+ yes, `flow.sh post xero/invoices/<Flow invoice id>/use-xero '{"organisationId":"<org>","xeroInvoiceId":"<Xero InvoiceID>"}'`.
232
+ Flow voids its copy (if not void yet), makes its Flow invoice from the
233
+ Xero one and gives it to the job. Same client only; one job per copy.
234
+ - **Held or in review:** `flow.sh get invoicing/jobs/<job id>/activity`
235
+ shows why. Release it: `flow.sh post invoicing/jobs/<job id>/hold '{"state":"none","reason":"<why>"}'`;
236
+ hold it: `'{"state":"held","reason":"<why>"}'` (or `"review"`).
237
+ A `history_import` hold is refused here: use the history import decisions.
238
+ 2. **Flow against Xero.** First get the lab id:
239
+ `flow.sh get invoicing/context` (`organisations[0].id`, below `<org>`).
240
+ - Refresh Flow's copy of Xero: `flow.sh post xero/mirror '{"organisationId":"<org>"}'`
241
+ - Xero invoices against Flow's: `flow.sh get 'xero/import?organisationId=<org>&show=check'`
242
+ (show: `check`, `linked`, `invoice_fits`, `job_fits`, `none`, `all`)
243
+ - Match them all again: `flow.sh post xero/import/match '{"organisationId":"<org>"}'`
244
+ - Link one Xero invoice to a Flow invoice: `flow.sh post xero/import/<xero invoice id>/link '{"organisationId":"<org>","invoiceId":"<id>"}'`
245
+ - Make the Flow invoice for one Xero invoice: `flow.sh post xero/import/<xero invoice id>/make '{"organisationId":"<org>","jobId":"<job id or null>"}'`
246
+ - Xero's state of some Flow invoices: `flow.sh post xero/invoices/status '{"organisationId":"<org>","invoiceIds":["<id>"]}'`
247
+ - Re-check one invoice after a fix in Xero: `flow.sh post xero/invoices/<id>/recheck '{"organisationId":"<org>"}'`
248
+ - Contacts: `flow.sh get 'xero/contacts/match?organisationId=<org>&contactId=<Flow contact id>'`; link to a Xero contact of another name: `flow.sh post xero/contacts/link '{"organisationId":"<org>","contactId":"<id>","xeroContactId":"<id>"}'`
249
+ 3. **Fix the cause with a rule.** If many jobs fail the same way, read
250
+ `invoicing/rule-fields`, propose one rule (`wait` or `skip`), and add it after
251
+ the person says yes. Line names that map to the wrong product:
252
+ `flow.sh get invoicing/line-mappings`, then change one with `put
253
+ invoicing/line-mappings/<id>`. Prices: `flow.sh get invoicing/price-list`.
254
+ 4. **Check Seazona itself** for one job with the Seazona read above (one order).
255
+
256
+ Links, "no invoice", rules and mappings are Flow records only: say what you
257
+ will change and do it after the person's yes. Mirror, match and recheck only
258
+ read Xero. Sending to Xero still goes through `invoicing/invoices/send-ready`.
259
+
260
+ ## Review queue, health and history import
261
+
262
+ Use these to find gaps in past billing: jobs Flow never saw, jobs held from
263
+ the history import, and days where Seazona, Flow and Xero disagree.
264
+
265
+ | Do this | Command |
266
+ | --- | --- |
267
+ | Flow health (build, Seazona sync, daily reconcile, history counts) | `flow.sh health` |
268
+ | Open review items | `flow.sh get 'invoicing/review?limit=100'` (also `kind=`, `state=resolved`) |
269
+ | History import state, backfill and batches | `flow.sh get seazona/history` |
270
+ | One batch, or its rows that wait | `flow.sh get 'seazona/history/batches/<id>?outcome=needs_person'` |
271
+ | Held history jobs that need a person | `flow.sh get 'seazona/history/ledger?state=needs_person'` (no state: all) |
272
+ | Decide batch jobs | `flow.sh post seazona/history/batches/<id>/decisions '{"decisions":[{"jobKey":"<key>","decision":"import"}]}'` (`import`, `skip`, or `null` to undo) |
273
+ | Apply a batch (once only) | `flow.sh post seazona/history/batches/<id>/apply` |
274
+ | Read past Seazona days into Flow | `flow.sh post seazona/history/backfill '{"from":"2026-07-01"}'`; stop: `post seazona/history/backfill/stop` |
275
+ | Let one held history job draft | `flow.sh post seazona/history/ledger/<job id>/release` |
276
+
277
+ How to read them:
278
+
279
+ - `health`: `integrations[].health.history` counts history jobs and open
280
+ batches; `reconcile` shows the last daily check, `daysDifferent` and
281
+ `openItems`; Seazona `missing` lists Seazona records Flow's copy lacks.
282
+ - Review items: `kind` says what differs (for example `reconcile.day`,
283
+ `job.changed`, `job.source_totals`); `evidence` holds the numbers. An item
284
+ closes by itself when its cause is fixed, so fix the cause (link, mapping,
285
+ rule, recheck) and read the list again.
286
+ - History jobs are held with reason `history_import`: they are past work,
287
+ most already billed. A matched one stays held. Release only a job that the
288
+ person agrees was never invoiced.
289
+
290
+ Decisions, apply, backfill and release change Flow records or spend Seazona
291
+ calls: say what you will do and act only after the person's yes. A backfill
292
+ uses the shared Seazona limit a few calls at a time; one at a time only.
293
+
294
+ ## Xero
295
+
296
+ Xero has its own `xero` command on this Gateway, for anything Flow does not
297
+ cover. Flow's send and the reconcile steps above use Flow's own Xero
298
+ connection, not yours.
299
+
300
+ ## Never
301
+
302
+ - Never print, paste or send the key file, a Xero token or a callback URL.
303
+ - Never send, void or delete without the person's yes in this chat.
304
+ - Never send a turned-off person's work again without asking: a send stopped as
305
+ `requester_disabled` means a person turned the assistant off.
306
+ - Never change Flow people, Integrations or owner settings: Flow refuses them.
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env bash
2
+ # Flow command for an AI assistant (docs/AGENT_ACCESS.md, "AI assistant key").
3
+ # flow.sh setup <https://flow url> save this lab's Flow address, make the key
4
+ # once, print only the key's SHA-256
5
+ # flow.sh get <path> GET <FLOW_URL>/api/<path>
6
+ # flow.sh post <path> [json] POST with an optional JSON body
7
+ # flow.sh put <path> <json> PUT with a JSON body
8
+ # flow.sh delete <path> DELETE
9
+ # flow.sh health GET <FLOW_URL>/health (public, no key sent)
10
+ # flow.sh link <kind> <id> print a Flow page address to give a person:
11
+ # invoice <invoice id>, reconcile <job id>,
12
+ # contact <contact id>, job <case number>
13
+ # flow.sh update replace this skill with the copy the
14
+ # saved Flow serves (its own release)
15
+ # The key stays in FLOW_AGENT_KEY_FILE (mode 600). It never goes on a command
16
+ # line or into the output.
17
+ set -euo pipefail
18
+
19
+ KEY_FILE="${FLOW_AGENT_KEY_FILE:-$HOME/.openclaw/credentials/flow-agent-key}"
20
+ URL_FILE="${KEY_FILE}.url"
21
+
22
+ die() { echo "flow.sh: $*" >&2; exit 2; }
23
+
24
+ # The key goes only to the Flow address saved at setup (or FLOW_URL). There is
25
+ # no default address, so a key never goes to another lab's Flow.
26
+ flow_url() {
27
+ local url="${FLOW_URL:-}"
28
+ [[ -n "$url" || ! -s "$URL_FILE" ]] || url="$(tr -d '\n' < "$URL_FILE")"
29
+ [[ "$url" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]] || die "no Flow address: run flow.sh setup https://<your Flow>"
30
+ printf '%s' "${url%/}"
31
+ }
32
+
33
+ setup() {
34
+ local url="${1:-}"
35
+ if [[ -n "$url" ]]; then
36
+ [[ "$url" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]] || die "the Flow address must be https://<host>"
37
+ mkdir -p "$(dirname "$URL_FILE")"
38
+ printf '%s\n' "${url%/}" > "$URL_FILE"
39
+ fi
40
+ echo "Flow address: $(flow_url)"
41
+ if [[ ! -s "$KEY_FILE" ]]; then
42
+ mkdir -p "$(dirname "$KEY_FILE")"
43
+ (umask 077 && openssl rand -hex 32 > "$KEY_FILE")
44
+ fi
45
+ chmod 600 "$KEY_FILE"
46
+ echo "FLOW_AGENT_KEY_SHA256=$(tr -d '\n' < "$KEY_FILE" | sha256sum | cut -d' ' -f1)"
47
+ echo "(For a second assistant, the host sets this digest as FLOW_AGENT_KEY_SHA256_2.)"
48
+ }
49
+
50
+ call() {
51
+ local method="$1" path="${2:-}" body="${3:-}"
52
+ [[ -n "$path" ]] || die "give a path, for example: get invoicing/invoices?status=ready"
53
+ [[ -s "$KEY_FILE" ]] || die "no key yet: run flow.sh setup https://<your Flow> first"
54
+ local args=(-sS -X "$method" -K - -H 'Accept: application/json' -w '\nHTTP %{http_code}\n')
55
+ if [[ -n "$body" ]]; then args+=(-H 'Content-Type: application/json' --data-binary "$body"); fi
56
+ printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '\n' < "$KEY_FILE")" \
57
+ | curl "${args[@]}" "$(flow_url)/api/${path#/}"
58
+ }
59
+
60
+ # Fetch SKILL.md and flow.sh from the saved Flow, so the skill always matches
61
+ # the release that Flow runs. Both files arrive first, then both replace the
62
+ # old ones; a bad answer changes nothing.
63
+ update() {
64
+ [[ -s "$KEY_FILE" ]] || die "no key yet: run flow.sh setup https://<your Flow> first"
65
+ local dir tmp file code
66
+ dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
67
+ tmp="$(mktemp -d "$dir/.update.XXXXXX")"
68
+ trap "rm -rf '$tmp'" EXIT
69
+ for file in SKILL.md flow.sh; do
70
+ code="$(printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '\n' < "$KEY_FILE")" \
71
+ | curl -sS -K - -o "$tmp/$file" -w '%{http_code}' "$(flow_url)/api/agent-skill/flow/$file")" \
72
+ || die "could not reach $(flow_url)"
73
+ [[ "$code" == 200 && -s "$tmp/$file" ]] || die "$file: HTTP $code from $(flow_url); nothing changed"
74
+ done
75
+ head -n 1 "$tmp/flow.sh" | grep -q '^#!/usr/bin/env bash' && bash -n "$tmp/flow.sh" \
76
+ || die "flow.sh from $(flow_url) is not a valid script; nothing changed"
77
+ grep -q '^name: flow$' "$tmp/SKILL.md" || die "SKILL.md from $(flow_url) is not the flow skill; nothing changed"
78
+ chmod 755 "$tmp/flow.sh"
79
+ chmod 644 "$tmp/SKILL.md"
80
+ mv -f "$tmp/SKILL.md" "$dir/SKILL.md"
81
+ mv -f "$tmp/flow.sh" "$dir/flow.sh"
82
+ echo "Skill updated from $(flow_url)"
83
+ }
84
+
85
+ # A Flow page address for a person: the saved Flow address plus the page's
86
+ # path. Ids are Flow's own (the `id` in an answer), never an invoice number.
87
+ UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
88
+ urlencode() {
89
+ local LC_ALL=C
90
+ local s="$1" out="" c i
91
+ for ((i = 0; i < ${#s}; i++)); do
92
+ c="${s:i:1}"
93
+ case "$c" in [A-Za-z0-9.~_-]) out+="$c" ;; *) out+="$(printf '%%%02X' "'$c")" ;; esac
94
+ done
95
+ printf '%s' "$out"
96
+ }
97
+ link() {
98
+ local kind="${1:-}" id="${2:-}" base
99
+ [[ -n "$id" && "$id" != null && "$id" != undefined ]] || die "give a kind and an id, for example: link invoice <invoice id>"
100
+ base="$(flow_url)"
101
+ case "$kind" in
102
+ invoice|reconcile|contact)
103
+ [[ "$id" =~ $UUID_RE ]] || die "$kind needs Flow's id (a UUID from the answer's \"id\"), not a number like INV-0123"
104
+ local page=invoices; [[ "$kind" == reconcile ]] && page=reconcile; [[ "$kind" == contact ]] && page=contacts
105
+ printf '%s/%s/%s\n' "$base" "$page" "$id" ;;
106
+ job) printf '%s/jobs?q=%s\n' "$base" "$(urlencode "$id")" ;;
107
+ *) die "link kinds: invoice, reconcile, contact, job" ;;
108
+ esac
109
+ }
110
+
111
+ case "${1:-}" in
112
+ setup) setup "${2:-}" ;;
113
+ get) call GET "${2:-}" ;;
114
+ post) call POST "${2:-}" "${3:-}" ;;
115
+ put) [[ -n "${3:-}" ]] || die "put needs a JSON body"; call PUT "${2:-}" "$3" ;;
116
+ delete) call DELETE "${2:-}" ;;
117
+ health) curl -sS -H 'Accept: application/json' -w '\nHTTP %{http_code}\n' "$(flow_url)/health" ;;
118
+ update) update ;;
119
+ link) link "${2:-}" "${3:-}" ;;
120
+ *) sed -n '2,15p' "$0"; exit 2 ;;
121
+ esac