@fias/create-fias-plugin 1.12.0 → 1.14.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fias/create-fias-plugin",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.20.0 -->
1
+ <!-- fias-sdk-guide-version: 2.22.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -12,9 +12,34 @@ package.json # Dependencies and scripts
12
12
  src/
13
13
  index.tsx # Entry point — must render into #root with <FiasProvider> wrapper
14
14
  App.tsx # Main component
15
+ public/ # Optional — static images, fonts, sounds referenced as '/name.png'
16
+ content/ # Optional — a content pack read with useFiasContent() (lessons, data, media)
15
17
  vite.config.ts # Vite dev server config (port 3100)
16
18
  ```
17
19
 
20
+ ## Where does each kind of data go?
21
+
22
+ Decide by who writes the data and when, before you write the code: moving data to a different home after you publish means a migration.
23
+
24
+ | What it is | Where it goes | What you get |
25
+ | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
+ | A few static images, fonts or sounds your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
+ | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
+ | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
+ | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
31
+ | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
32
+ | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
33
+ | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
34
+ | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
35
+
36
+ **Common mistakes:**
37
+
38
+ - **Inlining content in `src/`** — a JSON file of lessons, a level pack, base64 images. It counts against the 5 MB cap, and every user downloads all of it to use a little. Put it in `content/`.
39
+ - **Seeding content you wrote into the DataStore from the plugin.** Every user's session tries to write it, the first writer of a `shared` document owns it, and nothing reviews it. Ship it in `content/`. If it must also be searchable, keep a searchable copy in a `shared` collection, but display the pack's version.
40
+ - **Using the DataStore or `useFiasStorage()` for data that is the same for every user.** That is content.
41
+ - **Building `public/` paths at runtime**, e.g. `` `/music-${name}.ogg` ``. Only literal paths are rewritten to the CDN when you publish, so a path built in code 404s in production. Use a lookup table of literal strings, or move the files to `content/` and read them by name.
42
+
18
43
  ## SDK API Reference
19
44
 
20
45
  All hooks require the app to be wrapped in `<FiasProvider>`:
@@ -440,6 +465,85 @@ The text capabilities:
440
465
 
441
466
  Browse specific models with `npx fias-dev entities`.
442
467
 
468
+ #### Multi-turn conversations — `history`
469
+
470
+ `input` is the NEW user message. Send the earlier turns, oldest first, in `history`:
471
+
472
+ ```tsx
473
+ import { fitHistoryToLimits } from '@fias/arche-sdk';
474
+
475
+ const request = {
476
+ entityId: model.entityId,
477
+ systemPrompt: 'You are a helpful assistant.',
478
+ input: newMessage,
479
+ };
480
+ await invoke({
481
+ ...request,
482
+ // [{ role: 'user' | 'assistant', content }] — pass `request` so the history
483
+ // gets only the bytes the rest of the call leaves.
484
+ history: fitHistoryToLimits(messages, { maxTurns: 20, request }),
485
+ });
486
+ ```
487
+
488
+ Every turn is billed as input tokens on EVERY call, so send a window, not the whole transcript. Limits: 40 turns, 100,000 characters per turn, 150,000 in total (`ENTITY_INVOCATION_HISTORY_LIMITS`); a call over them, or a turn with a role other than `user` / `assistant`, is rejected with `INVALID_PARAMS`. The whole call must ALSO fit the bridge: 256 KB serialized for a text call, 1.5 MB with images (`ENTITY_INVOCATION_REQUEST_LIMITS`). A character can take up to 4 bytes (CJK 3, emoji 4, a newline or quote 2), so a history within the character limits can still be too large. `fitHistoryToLimits(turns, { request })` keeps the newest turns that fit both; `invoke()` refuses an oversized call with `REQUEST_TOO_LARGE` before sending it.
489
+
490
+ #### Reasoning, thinking, and several streams at once
491
+
492
+ ```tsx
493
+ const controller = new AbortController();
494
+ const result = await invoke(
495
+ {
496
+ entityId: model.entityId,
497
+ input,
498
+ history,
499
+ // Only for models with capabilities.reasoning; ignored otherwise.
500
+ parameters: { reasoning: { effort: 'medium', includeThinking: true } }, // effort: 'off' | 'low' | 'medium' | 'high'
501
+ },
502
+ {
503
+ onToken: (text) => appendAnswer(text), // this call's answer chunks
504
+ onThinking: (text) => appendThinking(text), // this call's reasoning text
505
+ signal: controller.signal, // controller.abort() → rejects with code 'ABORTED'
506
+ },
507
+ );
508
+ const cost = result.metadata?.usage?.costCredits; // what this reply cost the user, for a cost label
509
+ ```
510
+
511
+ - **Per-call callbacks** (`onToken` / `onThinking`) are scoped to that one call, so you can run several streams side by side — e.g. the same prompt against three models. A call that passes `onToken` leaves the hook's shared `streamingText` / `result` / `error` alone — its failure arrives only as the rejected promise.
512
+ - **Reasoning** uses more output tokens (billed) but always fits inside the model's output ceiling, so it never raises the credit hold. Set `includeThinking` only if you show the thinking — on some providers it raises the cost of a `'low'` request.
513
+ - **Aborting stops your UI, not the charge.** The platform finishes and bills the generation.
514
+ - A stream fails with `TIMEOUT` only after 2 minutes with no sign of life (the host sends keepalives during silent reasoning) or 15 minutes in total.
515
+ - `result.metadata.usage` has `inputTokens`, `outputTokens`, and `costCredits` when the platform reported them. Display only — the charge is already settled.
516
+ - A failed call rejects with a `FiasBridgeError`: branch on `err.code` (e.g. `INSUFFICIENT_CREDITS`, `PROVIDER_OVERLOADED`), and show `err.requestId` as a support reference when present.
517
+
518
+ ### `useTextModels()` — Let the user pick a text model
519
+
520
+ **Permission:** `entities:invoke` · **Returns:** `FiasTextModel[]`
521
+
522
+ For an app whose USER chooses the model (a chat app, a model comparison), read the platform's live catalog instead of hardcoding ids. Each entry's `entityId` goes straight into `invoke`. The list is `[]` until the host delivers it (usually immediately), and retired models drop out, so fall back to a `recommended` entry when a saved choice disappears.
523
+
524
+ ```tsx
525
+ import { useTextModels } from '@fias/arche-sdk';
526
+
527
+ function ModelPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
528
+ const models = useTextModels();
529
+ const selected = models.find((m) => m.entityId === value) ?? models.find((m) => m.recommended);
530
+ return (
531
+ <select value={selected?.entityId ?? ''} onChange={(e) => onChange(e.target.value)}>
532
+ {models.map((m) => (
533
+ <option key={m.entityId} value={m.entityId}>
534
+ {m.providerDisplayName} · {m.displayName}
535
+ {m.pricing
536
+ ? ` — ${m.pricing.inputCreditsPerMillionTokens}/${m.pricing.outputCreditsPerMillionTokens} cr per 1M tok`
537
+ : ''}
538
+ </option>
539
+ ))}
540
+ </select>
541
+ );
542
+ }
543
+ ```
544
+
545
+ `capabilities.vision` says the model accepts `images`; `capabilities.reasoning` says it honours `parameters.reasoning`. `pricing` is what the user pays per 1M tokens in credits (1 credit = 1¢), markup included — for display only.
546
+
443
547
  ### `useImageGeneration()` — Generate images via AI models
444
548
 
445
549
  **Permission:** `entities:image_generate`
@@ -479,6 +583,25 @@ generate({
479
583
 
480
584
  Mode values: `'text-to-image'` (baseline), `'image-to-image'` (requires `referenceImage`), `'vector'` (SVG output). Style values: `'illustration'` (stylized / digital-art) or `'photoreal'`.
481
585
 
586
+ **Several reference images, and output resolution.** A model whose `maxReferenceImages` (from `useImageEntities`) is more than one takes them together in `referenceImages` — Nano Banana 2 (`ent_modeldef_nano_banana_2`) takes up to 14. There is no role per image: the model reads them in order, so the prompt says which is which. `resolution` picks one of the model's `supportedResolutions`; higher costs more (`creditsPerImageByResolution`):
587
+
588
+ ```tsx
589
+ generate({
590
+ entityId: 'ent_modeldef_nano_banana_2',
591
+ prompt:
592
+ 'The knight from the first image, standing on a cliff at dawn, painted in the style of the second and third images',
593
+ referenceImages: [
594
+ { fileId: knight.fileId },
595
+ { fileId: styleA.fileId },
596
+ { fileId: styleB.fileId },
597
+ ],
598
+ size: '21:9',
599
+ resolution: '2K', // '512px' | '1K' (default) | '2K' | '4K'
600
+ });
601
+ ```
602
+
603
+ Use `referenceImage` for one picture and `referenceImages` for several — not both. More than the model takes returns `TOO_MANY_REFERENCE_IMAGES`; a resolution it does not list returns `INVALID_RESOLUTION`. Prefer `fileId` / `archeAssetId` over `dataUrl` when sending several: each `dataUrl` counts against the bridge message size. The result's `costCents` is what the call actually cost, markup included.
604
+
482
605
  Use `npx fias-dev entities --detail <entityId>` to check supported sizes, qualities, and styles for each model.
483
606
 
484
607
  #### Full example
@@ -545,14 +668,15 @@ const { entities, isLoading } = useImageEntities({
545
668
  supportsReferenceImage: true,
546
669
  });
547
670
  // entities[i]: { entityId, displayName, provider, modes, supportedSizes,
548
- // supportsReferenceImage, isPlatformRecommended,
549
- // creditsPerImage, estimatedDurationMs, company, strengths,
550
- // providerParams, ... }
671
+ // supportsReferenceImage, maxReferenceImages,
672
+ // supportedResolutions, defaultResolution, isPlatformRecommended,
673
+ // creditsPerImage, creditsPerImageByResolution,
674
+ // estimatedDurationMs, company, strengths, providerParams, ... }
551
675
  ```
552
676
 
553
677
  Each entry is scrubbed — only fields a UI legitimately needs (no provider model strings, no endpoints, no execution config).
554
678
 
555
- `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
679
+ `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. For a model priced by resolution it is the price at `defaultResolution`, and `creditsPerImageByResolution` has each resolution's; Nano Banana 2 also bills reference images and thinking as tokens on top (a fraction of a credit to a few credits). `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
556
680
 
557
681
  **Tunable engine parameters.** `providerParams` describes what a model exposes for tuning — enough to render a settings panel without hardcoding a thing:
558
682
 
@@ -564,6 +688,8 @@ const seed = entity.providerParams?.find((p) => p.key === 'seed');
564
688
  await generate({ entityId, prompt, providerParams: { seed: 42 } });
565
689
  ```
566
690
 
691
+ Nano Banana 2 exposes `seed`, `temperature` (0–2) and `thinking_level` (`'minimal'` default, or `'high'` — the model plans the picture first: better at layouts and directions like "seen from above", slower, and its thinking tokens are billed).
692
+
567
693
  **The `category` trap.** A param carrying `category: 'quality'` or `'style'` does NOT belong in `providerParams` — send it on the matching top-level field. Providers read those two from the top level only, and an unrecognized `providerParams` key is dropped without an error, so the image generates at the model's default **and you are billed for it anyway**:
568
694
 
569
695
  ```tsx
@@ -837,6 +963,17 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
837
963
  - **Workspaces.** The picker offers the files of the workspace the user is acting in — the workspace switcher in Fias: their Personal vault, or an org workspace they belong to — and `list()` returns the grants made in that workspace, so switching workspace changes what `list()` shows. A document you already hold a grant on stays readable by id (`get`, `getBytes`, `getDownloadUrl`, `saveContent`) whichever workspace the user is in: a grant belongs to the workspace the document lives in. A plugin never names a workspace itself. A member's role caps what they can share and read: a viewer can only share standard-sensitivity documents, and a document above a member's tier reads as `DOCUMENT_NOT_FOUND` even if another member granted it.
838
964
  - **Testable in the dev harness (mock mode).** `pick()` grants a small fixture set — a text file, a real PDF, and one deliberately over the 15 MB cap — and `list` / `get` / `getDownloadUrl` / `getBytes` all resolve against it, including the refusals (`BINARY_DOCUMENT` on a text read of a PDF, `CONSENTED_DOCUMENT_TOO_LARGE`, `DOCUMENT_NOT_FOUND`). Run the harness with `FIAS_HARNESS_VAULT_PICK=canceled` to exercise the cancel branch. Not available in the builder preview — `pick()` resolves `{ canceled: true }` there.
839
965
 
966
+ **Workspace-approved reads** — `userDocs.workspace.*`, permission `vault:workspace-documents:read`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). When the user is acting in an **org workspace** they belong to, whose admin (or its Company) has **approved your arche**, you can read that workspace's documents without a per-document pick — every document the acting member can see in Documents and Arche Saves, at their role, never proprietary:
967
+
968
+ ```tsx
969
+ const docs = await userDocs.workspace.list({ search: 'invoice' }); // the acting workspace's documents
970
+ const { document, content } = await userDocs.workspace.get(id, { includeContent: true });
971
+ const { url } = await userDocs.workspace.getDownloadUrl(id);
972
+ const { bytes } = await userDocs.workspace.getBytes(id);
973
+ ```
974
+
975
+ Two refusals to handle: `WORKSPACE_REQUIRED` (the user is in their Personal vault, or not a member of the workspace — offer `pick()` instead) and `ARCHE_NOT_APPROVED` (this workspace has not approved your arche, or approved it before it asked for this access — say so; a workspace or Company admin approves apps under Workspace Controls). **An approval covers exactly what the admin was shown:** if you add this permission, or start reading a new kind of workspace data, after being approved, those reads are refused with `ARCHE_NOT_APPROVED` until an admin approves again — plan releases that widen access with that in mind. The workspace is fixed when your arche opens (the user reopens it to switch), approval is revocable at once, and every read is logged where the admin can see it. Not available in the builder preview. In the dev harness, `mockWorkspace` in `fias-dev.config.json` (`"approved"` default, `"unapproved"`, `"personal"`) lets you exercise both refusals.
976
+
840
977
  **Editing the user's files** — permission `vault:user-documents:edit`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). `:edit` does not include `:read`: the grants you save under are created and listed through the read surface (`pick`, `list`, `get`), so a manifest with `:edit` alone can ask for nothing and open nothing.
841
978
 
842
979
  ```tsx
@@ -877,7 +1014,10 @@ const saved = await userDocs.saveContent({
877
1014
 
878
1015
  ### Sending a document for signature — Document Sign handoff
879
1016
 
880
- **Permission:** `navigation:open_arche`.
1017
+ **Permission:** `navigation:open_arche` to hand the document over. Reading the
1018
+ envelope's status afterwards is a separate act (`docsign:envelopes:read`) —
1019
+ your app only ever sees envelopes it started this way, and never their
1020
+ document bytes.
881
1021
 
882
1022
  Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
883
1023
  They place the signature fields and press Send there — a handoff never sends
@@ -921,7 +1061,9 @@ created, which is why the outcome is there.
921
1061
  **Treat the result as a pointer, never as a record.** It tells you which
922
1062
  envelope to look at; it is not evidence of what happened to it. Match it to an
923
1063
  outstanding request by `requestId`, consume it once, and never re-point an
924
- existing association because a `correlationId` matched.
1064
+ existing association because a `correlationId` matched. Anything you would act
1065
+ on — who signed, what is outstanding — comes from reading the envelope under
1066
+ `docsign:envelopes:read`, not from the payload.
925
1067
 
926
1068
  **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
927
1069
  be written down before you call `openArche`.** The host navigates away and
@@ -950,6 +1092,65 @@ local dev. Judging on arrival therefore rejects every genuine reply, not an
950
1092
  occasional one. Hold the result until you know what was outstanding, then
951
1093
  evaluate it.
952
1094
 
1095
+ ### `useDocumentSign()` — Follow an envelope you handed over
1096
+
1097
+ **Permission:** `docsign:envelopes:read`
1098
+
1099
+ The other half of the handoff above. Once the user has pressed Send in
1100
+ Document Sign, this is how your app learns what happened — status, who the
1101
+ recipients are, where each of them has got to, and the audit timeline.
1102
+
1103
+ ```tsx
1104
+ import { useDocumentSign } from '@fias/arche-sdk';
1105
+
1106
+ const sign = useDocumentSign();
1107
+
1108
+ const envelope = await sign.getEnvelope(approval.envelopeId);
1109
+ if (envelope.status === 'completed') markApproved(approval.id);
1110
+
1111
+ // The audit timeline, oldest first.
1112
+ const { events } = await sign.listEvents(approval.envelopeId);
1113
+ ```
1114
+
1115
+ **The contract:**
1116
+
1117
+ - **Only envelopes YOUR app started**, via a `sign_document` handoff. Not one
1118
+ the user created in Document Sign directly, not another app's. Two
1119
+ independent gates, both the user's: they grant access by pressing Send on an
1120
+ envelope you handed over (named to them on the send screen, revocable in
1121
+ Vault → App Access), and the platform separately requires the envelope to
1122
+ record your app as its origin. Either one missing means no read.
1123
+ - **This is where truth comes from.** The `sign_document_result` handoff is a
1124
+ pointer; it rides navigation state, which is forgeable. Write "signed" into
1125
+ an approval record only on the strength of a read.
1126
+ - **No list verb.** Keep the `envelopeId` on your own record and read by id —
1127
+ the same reason the result has to be matched to an outstanding `requestId`
1128
+ and consumed once.
1129
+ - **Drafts are not readable.** Send is the moment the user grants access, so a
1130
+ `saved_draft` result gives you an id to keep, and `getEnvelope` on it rejects
1131
+ until the envelope is actually sent. Do not poll a draft.
1132
+ - **One error for every refusal.** `ENVELOPE_NOT_FOUND_OR_NOT_GRANTED` covers
1133
+ an unknown id, an envelope you did not start, one on a workspace this user
1134
+ cannot act for, a draft, and one whose access the user has removed. Deliberately
1135
+ indistinguishable — a specific "it exists but you cannot see it" would let
1136
+ anyone probe for envelope ids. Treat it as "we can no longer follow this",
1137
+ not as a bug, and stop asking.
1138
+ - **You never see the document.** No bytes, no download URL — and no recipient
1139
+ email addresses either. Name, role, status and timestamps only. If your app
1140
+ supplied the recipients, it already has their addresses.
1141
+ - **The timeline is a projection, not the stored rows.** `type`, `actorKind`,
1142
+ `recipientId`, `createdAt`. Signers' IP addresses and user agents, the event
1143
+ payloads, and the hash-chain columns stay on the platform — they belong to
1144
+ the signing certificate, not to your app.
1145
+ - **There are no webhooks.** Nothing calls you when a recipient signs. Read
1146
+ when your UI needs to know — on mount, on a refresh the user asked for — not
1147
+ on a timer.
1148
+ - Terminal envelopes (`voided`, `expired`, `declined`) stay readable. A
1149
+ terminal outcome is part of the trail you came for; your UI should render it
1150
+ rather than leaving the record stuck on "in progress".
1151
+ - Not available in the builder preview: a preview tenant has no arche row, so
1152
+ it can never be an envelope's origin.
1153
+
953
1154
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
954
1155
 
955
1156
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -1453,9 +1654,9 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
1453
1654
 
1454
1655
  Other optional sub-fields: `currency: "usd"` (only USD supported today), `platformFeePercent` (override the default platform cut — non-standard).
1455
1656
 
1456
- **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `data:store`, `data:search`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `assets:read`, `navigation:open_arche`, `sandbox:vendored-libraries`
1657
+ **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `data:store`, `data:search`, `data:workspace`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `vault:user-documents:read`, `vault:user-documents:write`, `vault:user-documents:edit`, `vault:workspace-documents:read`, `assets:read`, `assets:community:read`, `assets:community:publish`, `navigation:open_arche`, `sandbox:vendored-libraries`, `ai:actions`
1457
1658
 
1458
- **Using AI:** Add `"entities:invoke"` to permissions, then use `useEntityInvocation()` with a model entity ID and your own `systemPrompt`. Browse available models with `npx fias-dev entities`.
1659
+ **Using AI:** Add `"entities:invoke"` to permissions, then use `useEntityInvocation()` with a model entity ID (or a `{ capability }` selector, or an entry from `useTextModels()`) and your own `systemPrompt`. Browse available models with `npx fias-dev entities`.
1459
1660
 
1460
1661
  **Defining IAP products (for `useFiasStore`):**
1461
1662
 
@@ -1514,6 +1715,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1514
1715
  ### Size and File Limits
1515
1716
 
1516
1717
  - **Bundle size:** Max 5 MB, measured **uncompressed** — the raw bytes of the final single HTML file with your JS and CSS inlined. Files in `public/` don't count toward it (they have their own caps: 4 MB per file, 120 MB and 600 files total)
1718
+ - **Content packs:** files in `content/` don't count toward the bundle either. If content (lessons, data, media) is what is pushing you toward the cap, move it there — see "Where does each kind of data go?" above. A pack holds up to 10,000 files and 500 MB (20 MB of text, 2,000 images)
1517
1719
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1518
1720
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1519
1721
 
@@ -1523,6 +1725,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1523
1725
  - `storage_write`: 120/minute
1524
1726
  - `storage_read`: 300/minute
1525
1727
  - `storage_list`, `storage_delete`: 60/minute
1728
+ - Content reads (`useFiasContent()`): 120/minute — read related files together with `getMany` (up to 100 per call)
1526
1729
 
1527
1730
  A separate **runaway-loop detector** fires if any method is called >50 times in 5 s and blocks that method for 10 s. Errors include the target, e.g. `Runaway loop detected: "storage_write" (path: __state/gameState) …` — the path tells you where to debounce at the source.
1528
1731
 
@@ -1612,6 +1815,8 @@ npm run submit
1612
1815
  # Builds, validates, packages, uploads, submits for AI review
1613
1816
  ```
1614
1817
 
1818
+ If the plugin has a `content/` folder, submit uploads it too — only the files that changed — and the review reads the whole pack on every publish. `npx fias-dev content check` validates it without submitting.
1819
+
1615
1820
  The first time you list a plugin in the marketplace there is a one-time
1616
1821
  listing fee; subsequent submissions of the same plugin only pay a small
1617
1822
  per-review fee. The harness fetches the exact amount from the server and
@@ -1,4 +1,4 @@
1
- <!-- fias-sdk-guide-version: 2.20.0 -->
1
+ <!-- fias-sdk-guide-version: 2.22.0 -->
2
2
 
3
3
  # FIAS Plugin Development Guide
4
4
 
@@ -12,9 +12,34 @@ package.json # Dependencies and scripts
12
12
  src/
13
13
  index.tsx # Entry point — must render into #root with <FiasProvider> wrapper
14
14
  App.tsx # Main component
15
+ public/ # Optional — static images, fonts, sounds referenced as '/name.png'
16
+ content/ # Optional — a content pack read with useFiasContent() (lessons, data, media)
15
17
  vite.config.ts # Vite dev server config (port 3100)
16
18
  ```
17
19
 
20
+ ## Where does each kind of data go?
21
+
22
+ Decide by who writes the data and when, before you write the code: moving data to a different home after you publish means a migration.
23
+
24
+ | What it is | Where it goes | What you get |
25
+ | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | Your code and small constants (config, a short word list) | The bundle (`src/`) | One file, max 5 MB uncompressed. Every user downloads all of it each time the plugin opens. |
27
+ | A few static images, fonts or sounds your UI names directly | `public/`, referenced as literal `'/name.png'` strings | Served from a CDN. Binary only (png, jpg, webp, gif, woff, woff2, ogg), top-level files only, 4 MB each, 600 files and 120 MB in total. |
28
+ | Content you write and ship with each version: lessons, articles, levels, reference data, large image or audio sets, fonts | A content pack: `content/` + `useFiasContent()` | Not bundled, so users download only what they open. Reviewed on every publish and read-only at runtime. You pay its storage and bandwidth; reads are free to users. |
29
+ | Images you want to change without republishing code (logos, maps, photos with alt text) | The asset library: `useArcheAssets()` + `fias-dev assets` | Managed from the CLI and refreshed without a code publish. |
30
+ | Records your app writes while it runs: progress, things users create, shared catalogues, leaderboards | `useFiasDataStore()` collections | Queryable, `user` or `shared` scope, write policies, optional semantic search. |
31
+ | Files only your app uses, for one user: drafts, caches, working data | `useFiasStorage()` | Free-form paths, private to the plugin and the user. Not shown in their Vault. |
32
+ | A small piece of UI state that should survive a reload (a setting, the last tab) | `usePersistentState()` | Debounced writes to `useFiasStorage()`. |
33
+ | Images one user shares with the app's other users | `useCommunityAssets()` | Moderated before anyone else sees them. |
34
+ | Documents the user should keep: files your app makes for them, or files they already have | `useVaultDocuments()` / `useVaultUserDocuments()` | In the user's My Data, where they outlive your plugin. Reading files they already had needs their consent. |
35
+
36
+ **Common mistakes:**
37
+
38
+ - **Inlining content in `src/`** — a JSON file of lessons, a level pack, base64 images. It counts against the 5 MB cap, and every user downloads all of it to use a little. Put it in `content/`.
39
+ - **Seeding content you wrote into the DataStore from the plugin.** Every user's session tries to write it, the first writer of a `shared` document owns it, and nothing reviews it. Ship it in `content/`. If it must also be searchable, keep a searchable copy in a `shared` collection, but display the pack's version.
40
+ - **Using the DataStore or `useFiasStorage()` for data that is the same for every user.** That is content.
41
+ - **Building `public/` paths at runtime**, e.g. `` `/music-${name}.ogg` ``. Only literal paths are rewritten to the CDN when you publish, so a path built in code 404s in production. Use a lookup table of literal strings, or move the files to `content/` and read them by name.
42
+
18
43
  ## SDK API Reference
19
44
 
20
45
  All hooks require the app to be wrapped in `<FiasProvider>`:
@@ -440,6 +465,85 @@ The text capabilities:
440
465
 
441
466
  Browse specific models with `npx fias-dev entities`.
442
467
 
468
+ #### Multi-turn conversations — `history`
469
+
470
+ `input` is the NEW user message. Send the earlier turns, oldest first, in `history`:
471
+
472
+ ```tsx
473
+ import { fitHistoryToLimits } from '@fias/arche-sdk';
474
+
475
+ const request = {
476
+ entityId: model.entityId,
477
+ systemPrompt: 'You are a helpful assistant.',
478
+ input: newMessage,
479
+ };
480
+ await invoke({
481
+ ...request,
482
+ // [{ role: 'user' | 'assistant', content }] — pass `request` so the history
483
+ // gets only the bytes the rest of the call leaves.
484
+ history: fitHistoryToLimits(messages, { maxTurns: 20, request }),
485
+ });
486
+ ```
487
+
488
+ Every turn is billed as input tokens on EVERY call, so send a window, not the whole transcript. Limits: 40 turns, 100,000 characters per turn, 150,000 in total (`ENTITY_INVOCATION_HISTORY_LIMITS`); a call over them, or a turn with a role other than `user` / `assistant`, is rejected with `INVALID_PARAMS`. The whole call must ALSO fit the bridge: 256 KB serialized for a text call, 1.5 MB with images (`ENTITY_INVOCATION_REQUEST_LIMITS`). A character can take up to 4 bytes (CJK 3, emoji 4, a newline or quote 2), so a history within the character limits can still be too large. `fitHistoryToLimits(turns, { request })` keeps the newest turns that fit both; `invoke()` refuses an oversized call with `REQUEST_TOO_LARGE` before sending it.
489
+
490
+ #### Reasoning, thinking, and several streams at once
491
+
492
+ ```tsx
493
+ const controller = new AbortController();
494
+ const result = await invoke(
495
+ {
496
+ entityId: model.entityId,
497
+ input,
498
+ history,
499
+ // Only for models with capabilities.reasoning; ignored otherwise.
500
+ parameters: { reasoning: { effort: 'medium', includeThinking: true } }, // effort: 'off' | 'low' | 'medium' | 'high'
501
+ },
502
+ {
503
+ onToken: (text) => appendAnswer(text), // this call's answer chunks
504
+ onThinking: (text) => appendThinking(text), // this call's reasoning text
505
+ signal: controller.signal, // controller.abort() → rejects with code 'ABORTED'
506
+ },
507
+ );
508
+ const cost = result.metadata?.usage?.costCredits; // what this reply cost the user, for a cost label
509
+ ```
510
+
511
+ - **Per-call callbacks** (`onToken` / `onThinking`) are scoped to that one call, so you can run several streams side by side — e.g. the same prompt against three models. A call that passes `onToken` leaves the hook's shared `streamingText` / `result` / `error` alone — its failure arrives only as the rejected promise.
512
+ - **Reasoning** uses more output tokens (billed) but always fits inside the model's output ceiling, so it never raises the credit hold. Set `includeThinking` only if you show the thinking — on some providers it raises the cost of a `'low'` request.
513
+ - **Aborting stops your UI, not the charge.** The platform finishes and bills the generation.
514
+ - A stream fails with `TIMEOUT` only after 2 minutes with no sign of life (the host sends keepalives during silent reasoning) or 15 minutes in total.
515
+ - `result.metadata.usage` has `inputTokens`, `outputTokens`, and `costCredits` when the platform reported them. Display only — the charge is already settled.
516
+ - A failed call rejects with a `FiasBridgeError`: branch on `err.code` (e.g. `INSUFFICIENT_CREDITS`, `PROVIDER_OVERLOADED`), and show `err.requestId` as a support reference when present.
517
+
518
+ ### `useTextModels()` — Let the user pick a text model
519
+
520
+ **Permission:** `entities:invoke` · **Returns:** `FiasTextModel[]`
521
+
522
+ For an app whose USER chooses the model (a chat app, a model comparison), read the platform's live catalog instead of hardcoding ids. Each entry's `entityId` goes straight into `invoke`. The list is `[]` until the host delivers it (usually immediately), and retired models drop out, so fall back to a `recommended` entry when a saved choice disappears.
523
+
524
+ ```tsx
525
+ import { useTextModels } from '@fias/arche-sdk';
526
+
527
+ function ModelPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
528
+ const models = useTextModels();
529
+ const selected = models.find((m) => m.entityId === value) ?? models.find((m) => m.recommended);
530
+ return (
531
+ <select value={selected?.entityId ?? ''} onChange={(e) => onChange(e.target.value)}>
532
+ {models.map((m) => (
533
+ <option key={m.entityId} value={m.entityId}>
534
+ {m.providerDisplayName} · {m.displayName}
535
+ {m.pricing
536
+ ? ` — ${m.pricing.inputCreditsPerMillionTokens}/${m.pricing.outputCreditsPerMillionTokens} cr per 1M tok`
537
+ : ''}
538
+ </option>
539
+ ))}
540
+ </select>
541
+ );
542
+ }
543
+ ```
544
+
545
+ `capabilities.vision` says the model accepts `images`; `capabilities.reasoning` says it honours `parameters.reasoning`. `pricing` is what the user pays per 1M tokens in credits (1 credit = 1¢), markup included — for display only.
546
+
443
547
  ### `useImageGeneration()` — Generate images via AI models
444
548
 
445
549
  **Permission:** `entities:image_generate`
@@ -479,6 +583,25 @@ generate({
479
583
 
480
584
  Mode values: `'text-to-image'` (baseline), `'image-to-image'` (requires `referenceImage`), `'vector'` (SVG output). Style values: `'illustration'` (stylized / digital-art) or `'photoreal'`.
481
585
 
586
+ **Several reference images, and output resolution.** A model whose `maxReferenceImages` (from `useImageEntities`) is more than one takes them together in `referenceImages` — Nano Banana 2 (`ent_modeldef_nano_banana_2`) takes up to 14. There is no role per image: the model reads them in order, so the prompt says which is which. `resolution` picks one of the model's `supportedResolutions`; higher costs more (`creditsPerImageByResolution`):
587
+
588
+ ```tsx
589
+ generate({
590
+ entityId: 'ent_modeldef_nano_banana_2',
591
+ prompt:
592
+ 'The knight from the first image, standing on a cliff at dawn, painted in the style of the second and third images',
593
+ referenceImages: [
594
+ { fileId: knight.fileId },
595
+ { fileId: styleA.fileId },
596
+ { fileId: styleB.fileId },
597
+ ],
598
+ size: '21:9',
599
+ resolution: '2K', // '512px' | '1K' (default) | '2K' | '4K'
600
+ });
601
+ ```
602
+
603
+ Use `referenceImage` for one picture and `referenceImages` for several — not both. More than the model takes returns `TOO_MANY_REFERENCE_IMAGES`; a resolution it does not list returns `INVALID_RESOLUTION`. Prefer `fileId` / `archeAssetId` over `dataUrl` when sending several: each `dataUrl` counts against the bridge message size. The result's `costCents` is what the call actually cost, markup included.
604
+
482
605
  Use `npx fias-dev entities --detail <entityId>` to check supported sizes, qualities, and styles for each model.
483
606
 
484
607
  #### Full example
@@ -545,14 +668,15 @@ const { entities, isLoading } = useImageEntities({
545
668
  supportsReferenceImage: true,
546
669
  });
547
670
  // entities[i]: { entityId, displayName, provider, modes, supportedSizes,
548
- // supportsReferenceImage, isPlatformRecommended,
549
- // creditsPerImage, estimatedDurationMs, company, strengths,
550
- // providerParams, ... }
671
+ // supportsReferenceImage, maxReferenceImages,
672
+ // supportedResolutions, defaultResolution, isPlatformRecommended,
673
+ // creditsPerImage, creditsPerImageByResolution,
674
+ // estimatedDurationMs, company, strengths, providerParams, ... }
551
675
  ```
552
676
 
553
677
  Each entry is scrubbed — only fields a UI legitimately needs (no provider model strings, no endpoints, no execution config).
554
678
 
555
- `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
679
+ `creditsPerImage` is what one image costs the user in credits, markup already included — it matches what the ledger deducts, so it is the figure to show. For a model priced by resolution it is the price at `defaultResolution`, and `creditsPerImageByResolution` has each resolution's; Nano Banana 2 also bills reference images and thinking as tokens on top (a fraction of a credit to a few credits). `estimatedDurationMs` sizes a progress indicator; `company` and `strengths` are model-info copy. All optional: absent means the model has nothing to declare, not that the data is missing.
556
680
 
557
681
  **Tunable engine parameters.** `providerParams` describes what a model exposes for tuning — enough to render a settings panel without hardcoding a thing:
558
682
 
@@ -564,6 +688,8 @@ const seed = entity.providerParams?.find((p) => p.key === 'seed');
564
688
  await generate({ entityId, prompt, providerParams: { seed: 42 } });
565
689
  ```
566
690
 
691
+ Nano Banana 2 exposes `seed`, `temperature` (0–2) and `thinking_level` (`'minimal'` default, or `'high'` — the model plans the picture first: better at layouts and directions like "seen from above", slower, and its thinking tokens are billed).
692
+
567
693
  **The `category` trap.** A param carrying `category: 'quality'` or `'style'` does NOT belong in `providerParams` — send it on the matching top-level field. Providers read those two from the top level only, and an unrecognized `providerParams` key is dropped without an error, so the image generates at the model's default **and you are billed for it anyway**:
568
694
 
569
695
  ```tsx
@@ -837,6 +963,17 @@ const { bytes } = await userDocs.getBytes(id); // raw bytes for PROCESSING binar
837
963
  - **Workspaces.** The picker offers the files of the workspace the user is acting in — the workspace switcher in Fias: their Personal vault, or an org workspace they belong to — and `list()` returns the grants made in that workspace, so switching workspace changes what `list()` shows. A document you already hold a grant on stays readable by id (`get`, `getBytes`, `getDownloadUrl`, `saveContent`) whichever workspace the user is in: a grant belongs to the workspace the document lives in. A plugin never names a workspace itself. A member's role caps what they can share and read: a viewer can only share standard-sensitivity documents, and a document above a member's tier reads as `DOCUMENT_NOT_FOUND` even if another member granted it.
838
964
  - **Testable in the dev harness (mock mode).** `pick()` grants a small fixture set — a text file, a real PDF, and one deliberately over the 15 MB cap — and `list` / `get` / `getDownloadUrl` / `getBytes` all resolve against it, including the refusals (`BINARY_DOCUMENT` on a text read of a PDF, `CONSENTED_DOCUMENT_TOO_LARGE`, `DOCUMENT_NOT_FOUND`). Run the harness with `FIAS_HARNESS_VAULT_PICK=canceled` to exercise the cancel branch. Not available in the builder preview — `pick()` resolves `{ canceled: true }` there.
839
965
 
966
+ **Workspace-approved reads** — `userDocs.workspace.*`, permission `vault:workspace-documents:read`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). When the user is acting in an **org workspace** they belong to, whose admin (or its Company) has **approved your arche**, you can read that workspace's documents without a per-document pick — every document the acting member can see in Documents and Arche Saves, at their role, never proprietary:
967
+
968
+ ```tsx
969
+ const docs = await userDocs.workspace.list({ search: 'invoice' }); // the acting workspace's documents
970
+ const { document, content } = await userDocs.workspace.get(id, { includeContent: true });
971
+ const { url } = await userDocs.workspace.getDownloadUrl(id);
972
+ const { bytes } = await userDocs.workspace.getBytes(id);
973
+ ```
974
+
975
+ Two refusals to handle: `WORKSPACE_REQUIRED` (the user is in their Personal vault, or not a member of the workspace — offer `pick()` instead) and `ARCHE_NOT_APPROVED` (this workspace has not approved your arche, or approved it before it asked for this access — say so; a workspace or Company admin approves apps under Workspace Controls). **An approval covers exactly what the admin was shown:** if you add this permission, or start reading a new kind of workspace data, after being approved, those reads are refused with `ARCHE_NOT_APPROVED` until an admin approves again — plan releases that widen access with that in mind. The workspace is fixed when your arche opens (the user reopens it to switch), approval is revocable at once, and every read is logged where the admin can see it. Not available in the builder preview. In the dev harness, `mockWorkspace` in `fias-dev.config.json` (`"approved"` default, `"unapproved"`, `"personal"`) lets you exercise both refusals.
976
+
840
977
  **Editing the user's files** — permission `vault:user-documents:edit`, declared TOGETHER WITH `vault:user-documents:read` (escalates to human review). `:edit` does not include `:read`: the grants you save under are created and listed through the read surface (`pick`, `list`, `get`), so a manifest with `:edit` alone can ask for nothing and open nothing.
841
978
 
842
979
  ```tsx
@@ -877,7 +1014,10 @@ const saved = await userDocs.saveContent({
877
1014
 
878
1015
  ### Sending a document for signature — Document Sign handoff
879
1016
 
880
- **Permission:** `navigation:open_arche`.
1017
+ **Permission:** `navigation:open_arche` to hand the document over. Reading the
1018
+ envelope's status afterwards is a separate act (`docsign:envelopes:read`) —
1019
+ your app only ever sees envelopes it started this way, and never their
1020
+ document bytes.
881
1021
 
882
1022
  Hand one of the user's Vault PDFs to Document Sign with recipients pre-filled.
883
1023
  They place the signature fields and press Send there — a handoff never sends
@@ -921,7 +1061,9 @@ created, which is why the outcome is there.
921
1061
  **Treat the result as a pointer, never as a record.** It tells you which
922
1062
  envelope to look at; it is not evidence of what happened to it. Match it to an
923
1063
  outstanding request by `requestId`, consume it once, and never re-point an
924
- existing association because a `correlationId` matched.
1064
+ existing association because a `correlationId` matched. Anything you would act
1065
+ on — who signed, what is outstanding — comes from reading the envelope under
1066
+ `docsign:envelopes:read`, not from the payload.
925
1067
 
926
1068
  **A handoff ENDS YOUR APP'S PROCESS. Anything you will need afterwards has to
927
1069
  be written down before you call `openArche`.** The host navigates away and
@@ -950,6 +1092,65 @@ local dev. Judging on arrival therefore rejects every genuine reply, not an
950
1092
  occasional one. Hold the result until you know what was outstanding, then
951
1093
  evaluate it.
952
1094
 
1095
+ ### `useDocumentSign()` — Follow an envelope you handed over
1096
+
1097
+ **Permission:** `docsign:envelopes:read`
1098
+
1099
+ The other half of the handoff above. Once the user has pressed Send in
1100
+ Document Sign, this is how your app learns what happened — status, who the
1101
+ recipients are, where each of them has got to, and the audit timeline.
1102
+
1103
+ ```tsx
1104
+ import { useDocumentSign } from '@fias/arche-sdk';
1105
+
1106
+ const sign = useDocumentSign();
1107
+
1108
+ const envelope = await sign.getEnvelope(approval.envelopeId);
1109
+ if (envelope.status === 'completed') markApproved(approval.id);
1110
+
1111
+ // The audit timeline, oldest first.
1112
+ const { events } = await sign.listEvents(approval.envelopeId);
1113
+ ```
1114
+
1115
+ **The contract:**
1116
+
1117
+ - **Only envelopes YOUR app started**, via a `sign_document` handoff. Not one
1118
+ the user created in Document Sign directly, not another app's. Two
1119
+ independent gates, both the user's: they grant access by pressing Send on an
1120
+ envelope you handed over (named to them on the send screen, revocable in
1121
+ Vault → App Access), and the platform separately requires the envelope to
1122
+ record your app as its origin. Either one missing means no read.
1123
+ - **This is where truth comes from.** The `sign_document_result` handoff is a
1124
+ pointer; it rides navigation state, which is forgeable. Write "signed" into
1125
+ an approval record only on the strength of a read.
1126
+ - **No list verb.** Keep the `envelopeId` on your own record and read by id —
1127
+ the same reason the result has to be matched to an outstanding `requestId`
1128
+ and consumed once.
1129
+ - **Drafts are not readable.** Send is the moment the user grants access, so a
1130
+ `saved_draft` result gives you an id to keep, and `getEnvelope` on it rejects
1131
+ until the envelope is actually sent. Do not poll a draft.
1132
+ - **One error for every refusal.** `ENVELOPE_NOT_FOUND_OR_NOT_GRANTED` covers
1133
+ an unknown id, an envelope you did not start, one on a workspace this user
1134
+ cannot act for, a draft, and one whose access the user has removed. Deliberately
1135
+ indistinguishable — a specific "it exists but you cannot see it" would let
1136
+ anyone probe for envelope ids. Treat it as "we can no longer follow this",
1137
+ not as a bug, and stop asking.
1138
+ - **You never see the document.** No bytes, no download URL — and no recipient
1139
+ email addresses either. Name, role, status and timestamps only. If your app
1140
+ supplied the recipients, it already has their addresses.
1141
+ - **The timeline is a projection, not the stored rows.** `type`, `actorKind`,
1142
+ `recipientId`, `createdAt`. Signers' IP addresses and user agents, the event
1143
+ payloads, and the hash-chain columns stay on the platform — they belong to
1144
+ the signing certificate, not to your app.
1145
+ - **There are no webhooks.** Nothing calls you when a recipient signs. Read
1146
+ when your UI needs to know — on mount, on a refresh the user asked for — not
1147
+ on a timer.
1148
+ - Terminal envelopes (`voided`, `expired`, `declined`) stay readable. A
1149
+ terminal outcome is part of the trail you came for; your UI should render it
1150
+ rather than leaving the record stuck on "in progress".
1151
+ - Not available in the builder preview: a preview tenant has no arche row, so
1152
+ it can never be an envelope's origin.
1153
+
953
1154
  ### `useArcheHandoff()` — Receive a document the user asked to open in your app
954
1155
 
955
1156
  **Permission:** none to receive. Reading the document is a separate act (`vault:user-documents:read`, via `useVaultUserDocuments().getBytes`).
@@ -1453,9 +1654,9 @@ The SDK also exports the following for advanced use cases. Most plugins don't ne
1453
1654
 
1454
1655
  Other optional sub-fields: `currency: "usd"` (only USD supported today), `platformFeePercent` (override the default platform cut — non-standard).
1455
1656
 
1456
- **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `data:store`, `data:search`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `assets:read`, `navigation:open_arche`, `sandbox:vendored-libraries`
1657
+ **Permissions:** `theme:read`, `user:profile:read`, `storage:sandbox`, `data:store`, `data:search`, `data:workspace`, `entities:invoke`, `entities:client_invoke`, `entities:image_generate`, `entities:image_edit`, `entities:audio_generate`, `entities:web_search`, `store:purchase`, `vault:documents:read`, `vault:documents:write`, `vault:user-documents:read`, `vault:user-documents:write`, `vault:user-documents:edit`, `vault:workspace-documents:read`, `assets:read`, `assets:community:read`, `assets:community:publish`, `navigation:open_arche`, `sandbox:vendored-libraries`, `ai:actions`
1457
1658
 
1458
- **Using AI:** Add `"entities:invoke"` to permissions, then use `useEntityInvocation()` with a model entity ID and your own `systemPrompt`. Browse available models with `npx fias-dev entities`.
1659
+ **Using AI:** Add `"entities:invoke"` to permissions, then use `useEntityInvocation()` with a model entity ID (or a `{ capability }` selector, or an entry from `useTextModels()`) and your own `systemPrompt`. Browse available models with `npx fias-dev entities`.
1459
1660
 
1460
1661
  **Defining IAP products (for `useFiasStore`):**
1461
1662
 
@@ -1514,6 +1715,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1514
1715
  ### Size and File Limits
1515
1716
 
1516
1717
  - **Bundle size:** Max 5 MB, measured **uncompressed** — the raw bytes of the final single HTML file with your JS and CSS inlined. Files in `public/` don't count toward it (they have their own caps: 4 MB per file, 120 MB and 600 files total)
1718
+ - **Content packs:** files in `content/` don't count toward the bundle either. If content (lessons, data, media) is what is pushing you toward the cap, move it there — see "Where does each kind of data go?" above. A pack holds up to 10,000 files and 500 MB (20 MB of text, 2,000 images)
1517
1719
  - **Dependencies:** Max 20, exact semver versions only (e.g., `"4.4.7"`, not `"^4.4.7"`)
1518
1720
  - Platform packages (`react`, `react-dom`, `@fias/arche-sdk`) are provided — do not include in `dependencies`
1519
1721
 
@@ -1523,6 +1725,7 @@ When you hit a constraint, say so AND tell the user: file an Entity request on t
1523
1725
  - `storage_write`: 120/minute
1524
1726
  - `storage_read`: 300/minute
1525
1727
  - `storage_list`, `storage_delete`: 60/minute
1728
+ - Content reads (`useFiasContent()`): 120/minute — read related files together with `getMany` (up to 100 per call)
1526
1729
 
1527
1730
  A separate **runaway-loop detector** fires if any method is called >50 times in 5 s and blocks that method for 10 s. Errors include the target, e.g. `Runaway loop detected: "storage_write" (path: __state/gameState) …` — the path tells you where to debounce at the source.
1528
1731
 
@@ -1612,6 +1815,8 @@ npm run submit
1612
1815
  # Builds, validates, packages, uploads, submits for AI review
1613
1816
  ```
1614
1817
 
1818
+ If the plugin has a `content/` folder, submit uploads it too — only the files that changed — and the review reads the whole pack on every publish. `npx fias-dev content check` validates it without submitting.
1819
+
1615
1820
  The first time you list a plugin in the marketplace there is a one-time
1616
1821
  listing fee; subsequent submissions of the same plugin only pay a small
1617
1822
  per-review fee. The harness fetches the exact amount from the server and