React Hooks
@theaiplatform/miniapp-sdk/react wraps the platform API in React hooks, so a surface
does not hand-roll loading state, failure classification, and refresh for every
capability it touches.
Hooks live in their own entry rather than under ./ui, because ./ui is the design
system. A surface that only wants a task list should not pull the component library.
Conventions
Every hook in this entry follows the same rules, so the per-hook sections below only note where one deviates.
What every hook returns
Resource hooks add data for the collection, find(id) for a synchronous lookup in
what is already loaded, and create / update / remove for writes.
No hook throws
Reading a capability the host has not installed reports isSupported: false rather
than throwing, so a surface that renders before the host is ready degrades instead of
crashing. This is why you must not test for support with Boolean(sdk.user): the SDK
proxy throws on property access outside a supported environment, which is the opposite
of what you want at a support check. Use the hook's isSupported.
denied is never inferred
failure.reason is an enumerated string. A withheld grant is always denied, and an
unrecognized error is never reported as one — telling somebody "not permitted" during a
backend outage sends them to fix a permission they already have.
Nothing is passed in
Each hook reads the installed sdk itself, the workspace defaults to the one your
surface is mounted in, and a specialist's model defaults to the workspace's choice.
Every hook's options argument exists so a test can inject a fake; supplying a capability
by hand in product code is almost always a mistake.
No shared cache yet
Each hook fetches independently, so two components calling useTasks fetch twice and
can briefly disagree. Hoist the hook and pass results down when that matters. A shared
cache is a deliberate open decision: adopting one changes every hook's semantics, so it
should happen once rather than per hook.
Choosing between a hook and a direct call
A hook earns its place when there is state to manage, a subscription to hold, or a multi-step sequence to run. Wrapping everything is not a goal — every hook is public API that has to be maintained and versioned.
Render cannot await. Without a hook, every surface hand-rolls the same useEffect +
useState + "did this unmount first" + "was it denied or did it fail" for a single
string. Outside render, call the SDK.
useUser
Reads the person using this surface.
Reference
useUser(options?)
Call useUser at the top level of your component to read the signed-in user.
Parameters
options(optional):user(optional): aMiniAppUserApito read instead of the installed one. For tests and non-standard hosts.
Returns
user:{ userId: string; displayName: string }, orundefineduntil the first read resolves.isSupported,isLoading,failure,reload: see Conventions.
Caveats
- Requires no permission and shows no prompt. Your mount context already carries
userId, so the name attached to it is not new authority. Anyone else's identity isuseWorkspaceMembers, which is granted separately. displayNameis host-derived. A package cannot supply or influence it.displayNamecan be an empty string when the signed-in profile has no usable name, which a guest mount can produce. Render a fallback rather than an empty row.- The hook does not poll. An identity cannot change without a remount, so
reloadexists only to retry a failed first read.
Usage
Attributing something the person did
userId is the same id the rest of the platform uses, so it is what you store on
records your backend keeps.
Prefer deriving the author on your backend from the verified session where you can. The client value is convenient for display; it is not proof of who is calling.
useWorkspace
Reads the workspace this surface is mounted in.
Reference
useWorkspace(options?)
Parameters
options(optional):workspace(optional): aMiniAppWorkspaceApito read instead of the installed one.
Returns
workspace:{ workspaceId: string; displayName: string }, orundefineduntil the first read resolves.isSupported,isLoading,failure,reload: see Conventions.
Caveats
- Requires a
workspace.readaction atlistenautonomy. displayNamefalls back to the workspace id rather than an empty string, so there is always something to print.- The hook does not poll. A workspace is not renamed out from under a mounted surface often enough to justify it.
- A
deniedreload clearsworkspace, so a revoked grant does not leave the name on screen.
Usage
Titling a surface without printing an id
A header that reads org_df93b96b-e1f9-43a0-a8c5-5c897e5ae15b tells nobody anything.
Prefer the workspace's name, and keep your package name as the fallback:
useWorkspaceMembers
Reads the workspace roster — joined members only.
Reference
useWorkspaceMembers(options?)
Parameters
options(optional):workspaceId(optional): defaults to the mounted workspace. A different workspace is refused by the host.enabled(optional):falseskips the read entirely, for a surface that only needs the roster conditionally.workspace(optional): aMiniAppWorkspaceApito read instead of the installed one.
Returns
members:{ userId: string; displayName: string }[]. Empty until the first read resolves, and empty when the grant is withheld.isSupported,isLoading,failure,reload: see Conventions.
Caveats
- Requires
workspace.read-membersatlisten— deliberately not theworkspace.readgrant that coverslistTeams,listProjectsanduseWorkspace. Those describe how work is organised; this describes people, most of whom never opened your package, so it is granted and revoked on its own. - Joined members only. An invitation is not a teammate, and pending invites would reveal hiring before it is announced.
- Each member is a user id and a name, and nothing else. The host's roster also carries email, role, title, timezone, invitation timestamps and who invited whom; none of it crosses this boundary.
- A
deniedreload clearsmembers, so a revoked grant cannot leave names rendered. - There is no avatar field, and adding one would not help: the generated surface CSP
is
img-src 'self' data:, so a remote image cannot load whatever your manifest declares. Draw initials, or have your own backend inline bytes as adata:URI.
Usage
Showing everyone, not only the people who used your app
This is the reason the hook exists. A miniapp that treats "people who have used me" as "the team" under-reports silently: someone who never opened it is invisible rather than missing, and any count you show is against the wrong denominator.
Join the roster to your own records, and keep the roster as the denominator:
Keeping a submission from someone who has left
A record whose author is no longer in members is not a bug to hide. Their week
happened; dropping it silently revises history. Append the orphans instead of discarding
them, and label them.
Degrading when the grant is withheld
denied is a normal state, not an error. Render what you can:
Troubleshooting
The roster is empty, but the workspace clearly has members
Check failure.reason before assuming the read worked. An empty array is also what a
withheld grant returns, and the two are indistinguishable without it:
If reason is denied, your package declares workspace.read-members but the
installation has not granted it. If reason is unsupported-host, the host predates
the capability.
It worked before the host update and now reports denied
workspace.read-members is a newer action than workspace.read. An installation that
predates it holds no grant for it, and grants are captured at install rather than
re-derived — so the roster is refused until the package is reinstalled or the grant is
added. That is deliberate: an existing grant must not silently widen into enumerating
people.
useSpecialist
Runs a turn against a specialist your package declares. See Specialists for the declaration contract and the channel-versus-room model.
Reference
useSpecialist(specialistIdOrOptions)
Parameters
specialistIdOrOptions: theslug@versionstring, or an options object:specialistId: theslug@versionto run.parse(optional): types the result. Returnnullorundefinedto reject an answer asoff-contract— both conventions work, so a parser using either needs no adapter. Omit it anddatais the raw text.
Returns
data: the parsed result, or the raw text whenparseis omitted.status:unsupported|idle|running|ready|failed.isRunning:status === 'running'.run(prompt),regenerate(),reset(): stable for the hook's whole life, so they are safe in a dependency array and as a memoized child's prop.isSupported,failure: see Conventions.
Caveats
- A failure outranks a previous answer, so a failed
regeneratenever leaves a stale result presented as current. - While a turn is in flight,
runis a no-op — matching the host, which serializes turns on one room.reset()does not let a second turn start while the host is still working on the first. - There is no cancellation. The host offers a guest no way to stop a turn it started,
and an
abort()that only stopped the caller listening would be misleading while the turn kept running.
Usage
Running several specialists
Call the hook once per specialist — the same shape as one query per key. Each gets its own room keyed on (workspace, package, specialist), so they neither share history nor queue behind each other.
Outside React
The same lifecycle without React is runSpecialist from
@theaiplatform/miniapp-sdk/sdk.
useTasks
Lists and mutates tasks.
Reference
useTasks(options?)
Returns
data: the loaded tasks.find(id): a synchronous lookup in already-loaded data. Tasks have no by-id read, so this does not fetch.create/update/remove: resolve with the affected item, orundefined/falseon failure with the cause infailure.isSupported,isLoading,isBusy,failure,reload: see Conventions.
Caveats
useTasksfollows every host page before it publishesdata.- Writes reload rather than patching local state. The host owns task shape — status normalization, assignee resolution, archived transitions — so echoing a locally-mutated task risks showing something the host would describe differently.
isLoadingcovers the first load, for a skeleton.isBusycovers any read or write, for disabling controls.remove, notdelete:deleteis a reserved word, soconst { delete } = useTasks()will not parse.data, nottasks: one shared shape across hooks. Rename at the call site where a domain name reads better —const { data: tasks } = useTasks().
Troubleshooting
find returns undefined for a task I know exists
find searches loaded data. If the task was created outside this surface, reload()
first. Not every resource supports every operation, because the platform APIs do not:
tasks have a list but no by-id read, and projects have a by-id read but no list. A missing
method means a missing platform operation rather than an oversight.