@possibl/rcrt-sdk 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@possibl/rcrt-sdk` are documented here. The
4
+ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
+ this package follows [SemVer](https://semver.org/spec/v2.0.0.html) once
6
+ published.
7
+
8
+ ## [0.1.0] — 2026-04-30
9
+
10
+ First public release. Drops the `-alpha.1` suffix; signals the SDK is
11
+ ready for frontend consumption (canonical RCRT mobile + web template
12
+ imports it directly).
13
+
14
+ ### Added — modules
15
+
16
+ - **`FilesModule`** (`client.files`) — multipart upload, list, get,
17
+ signed-URL download, server-side text extraction, soft delete.
18
+ Defaults to `tenant` scope; supports `org` / `user` for cross-
19
+ workspace assets.
20
+ - **`SessionsModule`** (`client.sessions`) — `getConstellation()`
21
+ graph view of related sessions, `listParticipants` /
22
+ `addParticipant` / `removeParticipant` for multi-agent sessions.
23
+ - **`CapabilitiesModule`** (`client.capabilities`) — `list(type)` for
24
+ tools / agents / services / knowledge, plus `getChattableAgents()`
25
+ helper that filters `interpret:promptable` to entries tagged
26
+ `interface:chat` / `interface:chat-default`.
27
+ - **`IdentityModule.getUserProfile` / `updateUserProfile`** — workspace-
28
+ scoped editable profile breadcrumb. Returns `null` (instead of
29
+ throwing) when no profile has been written yet.
30
+ - **`GrantsModule.resolveService(name)`** — server-side credential
31
+ resolution for tools that need to act on behalf of a workspace's
32
+ active service grant.
33
+
34
+ ### Notes
35
+
36
+ - `IdentityModule.listPendingInvitations` / `acceptInvitation` /
37
+ `declineInvitation` were already shipped in the alpha — no change.
38
+ - Global-events SSE (`/v1/events`) is exposed as `chat.globalStream`
39
+ rather than its own module — an extra module would have meant a
40
+ duplicate config builder.
41
+ - Tenant admin endpoints (members, resource overrides) deliberately
42
+ out of scope for `0.1.0`. Will land in a `0.2.x` once a consumer
43
+ needs them.
44
+
45
+ ### Dependencies
46
+
47
+ - No runtime deps. The SDK is fetch + EventSource + plain TS. Bring
48
+ your own `EventSource` polyfill on React Native (see README).
49
+
50
+ ### Compatibility
51
+
52
+ - Node 18+ (native fetch + WHATWG URL).
53
+ - Browsers with `fetch`, `URL`, `URLSearchParams`, `EventSource`.
54
+ - React Native — pass an `eventSource` constructor in
55
+ `chat.sessionStream({ eventSource })` and bring a fetch polyfill if
56
+ using a runtime older than RN 0.74.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @possibl/rcrt-sdk
2
+
3
+ TypeScript client for the [RCRT](https://github.com/possibl-ai/rcrt-v2)
4
+ multi-tenant AI BaaS. Isomorphic — works in browsers, Node 18+, and
5
+ React Native with a small polyfill.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @possibl/rcrt-sdk
11
+ # or pnpm / yarn / bun
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```ts
17
+ import { RcrtClient, staticTokenProvider } from '@possibl/rcrt-sdk';
18
+
19
+ const rcrt = new RcrtClient({
20
+ apiUrl: 'https://rcrt-api-gateway-<hash>.run.app',
21
+ tokenProvider: staticTokenProvider(process.env.WORKSPACE_API_KEY!),
22
+ });
23
+
24
+ // 1. Resolve workspace (for tests — UI apps go through Firebase):
25
+ const { tenants } = await rcrt.auth.me().then((m) => ({ tenants: m.tenants }));
26
+ rcrt.setTenantId(tenants[0].id);
27
+
28
+ // 2. Send a chat:
29
+ const { session_id } = await rcrt.chat.send({
30
+ message: 'Summarise my inbox',
31
+ target_agent: 'life-coordinator',
32
+ });
33
+
34
+ // 3. Stream the reply:
35
+ const stream = rcrt.chat.sessionStream(session_id, {
36
+ onDelta: ({ delta }) => process.stdout.write(delta),
37
+ onMessage: (bc) => {
38
+ if (bc.tags.includes('interpret:pending-action')) {
39
+ const card = extractCard(bc);
40
+ console.log('card', card?.layout, card?.header.title);
41
+ }
42
+ },
43
+ });
44
+
45
+ // 4. When the user taps an action on a card:
46
+ await rcrt.cards.resolve(cardBreadcrumbId, { status: 'approved' });
47
+
48
+ stream.close();
49
+ ```
50
+
51
+ ## Browser + Firebase
52
+
53
+ ```ts
54
+ import { getAuth } from 'firebase/auth';
55
+ import { RcrtClient, TokenProvider } from '@possibl/rcrt-sdk';
56
+
57
+ const auth = getAuth();
58
+ const tokenProvider: TokenProvider = {
59
+ async getIdToken() {
60
+ const user = auth.currentUser;
61
+ if (!user) throw new Error('not signed in');
62
+ return user.getIdToken(false);
63
+ },
64
+ };
65
+
66
+ const rcrt = new RcrtClient({
67
+ apiUrl: import.meta.env.VITE_RCRT_API,
68
+ tokenProvider,
69
+ });
70
+ ```
71
+
72
+ ## React Native
73
+
74
+ React Native has no built-in `EventSource`. Install a polyfill and
75
+ pass it through:
76
+
77
+ ```bash
78
+ npm install react-native-sse
79
+ ```
80
+
81
+ ```ts
82
+ import EventSource from 'react-native-sse';
83
+
84
+ const stream = rcrt.chat.sessionStream(sessionId, handlers, {
85
+ eventSource: EventSource,
86
+ useHeaderAuth: true, // RN polyfill supports custom headers
87
+ });
88
+ ```
89
+
90
+ ## What's exported
91
+
92
+ | Symbol | Purpose |
93
+ | --- | --- |
94
+ | `RcrtClient` | The client. One instance per workspace session. |
95
+ | `TokenProvider` | Interface — wire any IdP. |
96
+ | `staticTokenProvider` | Static-token convenience (tests, `tk_*` workspace keys). |
97
+ | `ApiError`, `SdkError` | Error classes. Typed + discriminable by `.status` and `.detail.code`. |
98
+ | `Breadcrumb`, `Card`, `Row`, `ChartSpec`, ... | Every public type from the RCRT contract. Re-exportable as your UI types. |
99
+
100
+ Browse `src/index.ts` for the full re-export list.
101
+
102
+ ### Module surface on `RcrtClient`
103
+
104
+ ```ts
105
+ const rcrt = new RcrtClient({ apiUrl, tokenProvider });
106
+
107
+ rcrt.auth // identity, tenants, invitations, user profile
108
+ rcrt.breadcrumbs // CRUD + tag query + semantic search
109
+ rcrt.chat // send + sessionStream (per-session SSE) + globalStream (/v1/events)
110
+ rcrt.cards // JIT-UI card resolve + helpers
111
+ rcrt.grants // OAuth grants + initiate + resolveService
112
+ rcrt.files // upload / list / get / signed download / text extract / delete
113
+ rcrt.sessions // constellation + participants
114
+ rcrt.capabilities // tools / agents / services / knowledge discovery
115
+ ```
116
+
117
+ ## Design notes
118
+
119
+ - **Isomorphic**: zero DOM or Node-only APIs in the core. The only
120
+ platform-touching code is the SSE client, and it takes an
121
+ `EventSource` constructor through config.
122
+ - **Token provider is the seam**: the SDK never touches Firebase /
123
+ Auth0 / Clerk directly. You bring a `TokenProvider` and it asks.
124
+ - **Everything is a breadcrumb**: `rcrt.breadcrumbs.*` is the
125
+ fundamental CRUD surface; all other modules build on it. `cards`,
126
+ `grants`, `auth` are conveniences.
127
+ - **Optimistic locking just works**: `breadcrumbs.update(id, req, {
128
+ autoRetryConflict: true })` and `cards.resolve(...)` both refetch
129
+ and retry on 409.
130
+ - **Errors are normalised**: the backend is mid-migration from
131
+ `{"error": "string"}` to a typed `{"error": {"code", "message",
132
+ "details"}}` envelope. `ApiError.detail.code` is set either way.
133
+
134
+ ## Matching docs
135
+
136
+ Full narrative + concepts + operations playbook in
137
+ [`packages/docs/`](../docs/). Start with:
138
+
139
+ - [`docs/guides/01-quickstart.md`](../docs/guides/01-quickstart.md)
140
+ - [`docs/concepts/05-jit-ui.md`](../docs/concepts/05-jit-ui.md)
141
+ - [`docs/openapi/v1.yaml`](../docs/openapi/v1.yaml)
142
+
143
+ ## Status
144
+
145
+ **0.1.0** — first public release. Used by `possibl-ai/rcrt-template-mobile`
146
+ and downstream apps (e.g. `possibl-ai/ritual-app`, `possibl-ai/properlii-mobile`)
147
+ as their canonical RCRT client. Surface stable; minor adjustments
148
+ possible before 1.0.
149
+
150
+ See [`CHANGELOG.md`](./CHANGELOG.md) for what's new in this release.
151
+
152
+ ## Licence
153
+
154
+ MIT.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@possibl/rcrt-sdk",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for the RCRT multi-tenant AI BaaS — isomorphic (web + node + React Native)",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./types": {
14
+ "types": "./src/types/index.ts",
15
+ "default": "./src/types/index.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "keywords": [
24
+ "rcrt",
25
+ "ai",
26
+ "baas",
27
+ "agents",
28
+ "jit-ui",
29
+ "typescript",
30
+ "sdk"
31
+ ],
32
+ "author": "Possibl (https://possibl.ai)",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/possibl-ai/rcrt-v2",
37
+ "directory": "packages/rcrt-sdk"
38
+ },
39
+ "homepage": "https://github.com/possibl-ai/rcrt-v2/tree/development/packages/docs",
40
+ "bugs": "https://github.com/possibl-ai/rcrt-v2/issues",
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "scripts": {
45
+ "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json",
46
+ "build": "tsc",
47
+ "test": "tsx --test tests/*.test.ts"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^20.0.0",
51
+ "tsx": "^4.19.2",
52
+ "typescript": "~5.6.0"
53
+ }
54
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * TokenProvider — the seam between RCRT and your identity source.
3
+ *
4
+ * The SDK never talks to Firebase / Auth0 / Clerk directly. You wire
5
+ * up whichever IdP you use by implementing this interface once. On
6
+ * every request the SDK calls `getIdToken()` to get a fresh bearer.
7
+ *
8
+ * The provider owns refresh. If your IdP supports it (Firebase does),
9
+ * it should return a token that's valid for at least the next ~60s;
10
+ * the SDK doesn't track expiry itself.
11
+ *
12
+ * Example Firebase (web):
13
+ *
14
+ * import { getAuth } from 'firebase/auth';
15
+ * const auth = getAuth();
16
+ * const tokenProvider: TokenProvider = {
17
+ * async getIdToken() {
18
+ * const u = auth.currentUser;
19
+ * if (!u) throw new SdkError('NOT_SIGNED_IN', 'Sign in first');
20
+ * return u.getIdToken(false);
21
+ * },
22
+ * };
23
+ *
24
+ * Example test stub:
25
+ *
26
+ * const tokenProvider: TokenProvider = {
27
+ * async getIdToken() { return 'tk_test_workspace_api_key'; },
28
+ * };
29
+ */
30
+
31
+ import { SdkError } from './errors.js';
32
+
33
+ export interface TokenProvider {
34
+ /**
35
+ * Return a bearer token for the next request. Throws or rejects if
36
+ * the user isn't signed in / the key is missing.
37
+ *
38
+ * Implementations SHOULD refresh transparently if they know the
39
+ * token is close to expiry. The SDK calls this per-request and
40
+ * doesn't cache.
41
+ */
42
+ getIdToken(): Promise<string>;
43
+
44
+ /** Optional hook: called on 401. Implementations can force a refresh. */
45
+ onUnauthorized?(): Promise<void>;
46
+ }
47
+
48
+ /** Convenience: a provider that returns a static string. For workspace API keys or tests. */
49
+ export function staticTokenProvider(token: string): TokenProvider {
50
+ if (!token) throw new SdkError('EMPTY_TOKEN', 'staticTokenProvider called with empty token');
51
+ return {
52
+ async getIdToken() {
53
+ return token;
54
+ },
55
+ };
56
+ }
package/src/authn.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Identity module — `/v1/auth/*` endpoints.
3
+ *
4
+ * Distinct from the TokenProvider abstraction: this is the server-side
5
+ * identity surface that runs _after_ you've presented a bearer token.
6
+ * See `packages/docs/guides/02-auth.md`.
7
+ */
8
+
9
+ import type { FetchContext } from './internal/fetch.js';
10
+ import { request } from './internal/fetch.js';
11
+
12
+ export interface MeResponse {
13
+ user: {
14
+ id: string;
15
+ email: string;
16
+ name?: string;
17
+ picture?: string;
18
+ };
19
+ is_platform_admin: boolean;
20
+ organizations: Array<{ id: string; name: string; role: string }>;
21
+ tenants: Array<{ id: string; name: string; role: string }>;
22
+ active_tenant?: { id: string; name: string; role: string };
23
+ permissions?: string[];
24
+ grants?: unknown[];
25
+ }
26
+
27
+ export interface Tenant {
28
+ id: string;
29
+ name: string;
30
+ org_id?: string;
31
+ role?: string;
32
+ }
33
+
34
+ export interface PendingInvitation {
35
+ id: string;
36
+ org_id?: string | null;
37
+ tenant_id?: string | null;
38
+ email: string;
39
+ role: string;
40
+ status: 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled';
41
+ expires_at: string;
42
+ invited_by?: { id: string; name?: string };
43
+ }
44
+
45
+ export class IdentityModule {
46
+ constructor(private readonly ctx: FetchContext) {}
47
+
48
+ /**
49
+ * `GET /v1/auth/me` — returns identity + all accessible workspaces.
50
+ *
51
+ * Creates the user row on first sign-in. Call this before any other
52
+ * RCRT endpoint for brand-new Firebase users.
53
+ */
54
+ async me(): Promise<MeResponse> {
55
+ return request<MeResponse>(this.ctx, '/v1/auth/me', { skipTenant: true });
56
+ }
57
+
58
+ /** List workspaces the current user is a member of. */
59
+ async listTenants(): Promise<Tenant[]> {
60
+ const res = await request<Tenant[] | { tenants: Tenant[] }>(this.ctx, '/v1/auth/tenants', {
61
+ skipTenant: true,
62
+ });
63
+ return Array.isArray(res) ? res : (res.tenants ?? []);
64
+ }
65
+
66
+ /** Confirm workspace membership. Client code is still responsible for setting X-Tenant-ID. */
67
+ async selectTenant(tenantId: string): Promise<void> {
68
+ await request<void>(this.ctx, `/v1/auth/tenants/${tenantId}/select`, {
69
+ method: 'POST',
70
+ skipTenant: true,
71
+ });
72
+ }
73
+
74
+ /** `POST /v1/auth/logout` — server-side no-op; included for symmetry + audit. */
75
+ async logout(): Promise<void> {
76
+ await request<void>(this.ctx, '/v1/auth/logout', {
77
+ method: 'POST',
78
+ skipTenant: true,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * `POST /v1/auth/delete-account` — cascading account deletion.
84
+ *
85
+ * **NOT YET ON `development`** at publish time — pending a follow-up
86
+ * PR. The SDK method exists so consumer code can compile against the
87
+ * intended shape; invoking it against a gateway that lacks the
88
+ * handler returns 404.
89
+ */
90
+ async deleteAccount(): Promise<void> {
91
+ await request<void>(this.ctx, '/v1/auth/delete-account', {
92
+ method: 'POST',
93
+ skipTenant: true,
94
+ });
95
+ }
96
+
97
+ /** Pending invitations keyed by the caller's email. */
98
+ async listPendingInvitations(): Promise<PendingInvitation[]> {
99
+ const res = await request<
100
+ PendingInvitation[] | { invitations: PendingInvitation[]; total?: number }
101
+ >(this.ctx, '/v1/invitations/pending');
102
+ return Array.isArray(res) ? res : (res.invitations ?? []);
103
+ }
104
+
105
+ async acceptInvitation(token: string): Promise<void> {
106
+ await request<void>(this.ctx, '/v1/invitations/accept', {
107
+ method: 'POST',
108
+ body: { token },
109
+ });
110
+ }
111
+
112
+ async declineInvitation(invitationId: string): Promise<void> {
113
+ await request<void>(this.ctx, `/v1/invitations/${invitationId}/decline`, {
114
+ method: 'POST',
115
+ });
116
+ }
117
+
118
+ /**
119
+ * `GET /v1/user/profile` — workspace-scoped user profile breadcrumb.
120
+ *
121
+ * Distinct from `me()` — this is the user's editable profile content
122
+ * (name, timezone, preferences) rather than their identity envelope.
123
+ * Returns `null` when no profile has been written yet (404 swallowed).
124
+ */
125
+ async getUserProfile(): Promise<UserProfileBreadcrumb | null> {
126
+ try {
127
+ return await request<UserProfileBreadcrumb>(this.ctx, '/v1/user/profile');
128
+ } catch (err) {
129
+ // 404 is the no-profile-yet case — caller renders the onboarding flow.
130
+ if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 404) {
131
+ return null;
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * `PUT /v1/user/profile` — upsert. Body fields land on the
139
+ * underlying breadcrumb's `content` payload.
140
+ */
141
+ async updateUserProfile(
142
+ patch: Record<string, unknown>,
143
+ ): Promise<UserProfileBreadcrumb> {
144
+ return request<UserProfileBreadcrumb>(this.ctx, '/v1/user/profile', {
145
+ method: 'PUT',
146
+ body: patch,
147
+ });
148
+ }
149
+ }
150
+
151
+ export interface UserProfileBreadcrumb {
152
+ id: string;
153
+ title?: string;
154
+ content: Record<string, unknown>;
155
+ tags: string[];
156
+ version: number;
157
+ created_at?: string;
158
+ updated_at?: string;
159
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Breadcrumbs module — CRUD + tag query + semantic search.
3
+ *
4
+ * See `packages/docs/guides/04-breadcrumbs.md` for the narrative
5
+ * description of each operation.
6
+ */
7
+
8
+ import type { FetchContext } from './internal/fetch.js';
9
+ import { request } from './internal/fetch.js';
10
+ import type {
11
+ Breadcrumb,
12
+ BreadcrumbResponse,
13
+ CreateBreadcrumbRequest,
14
+ UpdateBreadcrumbRequest,
15
+ QueryByTagsOptions,
16
+ SemanticSearchOptions,
17
+ } from './types/breadcrumb.js';
18
+ import { ApiError, SdkError } from './errors.js';
19
+
20
+ export class BreadcrumbsModule {
21
+ constructor(private readonly ctx: FetchContext) {}
22
+
23
+ /** `POST /v1/breadcrumbs` */
24
+ async create(req: CreateBreadcrumbRequest): Promise<Breadcrumb> {
25
+ const res = await request<BreadcrumbResponse | Breadcrumb>(this.ctx, '/v1/breadcrumbs', {
26
+ method: 'POST',
27
+ body: req,
28
+ });
29
+ return 'breadcrumb' in (res as BreadcrumbResponse)
30
+ ? (res as BreadcrumbResponse).breadcrumb
31
+ : (res as Breadcrumb);
32
+ }
33
+
34
+ /** `GET /v1/breadcrumbs?tags=...` — AND semantics. */
35
+ async queryByTags(tags: string[], options: QueryByTagsOptions = {}): Promise<Breadcrumb[]> {
36
+ if (tags.length === 0) {
37
+ throw new SdkError('EMPTY_TAGS', 'queryByTags requires at least one tag');
38
+ }
39
+ const res = await request<Breadcrumb[] | { breadcrumbs: Breadcrumb[] }>(this.ctx, '/v1/breadcrumbs', {
40
+ query: {
41
+ tags: tags.join(','),
42
+ limit: options.limit,
43
+ offset: options.offset,
44
+ name: options.name,
45
+ order: options.order,
46
+ },
47
+ });
48
+ return Array.isArray(res) ? res : (res.breadcrumbs ?? []);
49
+ }
50
+
51
+ /** `GET /v1/breadcrumbs/search?q=...` — cosine-similarity semantic search. */
52
+ async search(q: string, options: SemanticSearchOptions = {}): Promise<Breadcrumb[]> {
53
+ const query: Record<string, string | number | undefined> = {
54
+ q,
55
+ limit: options.limit,
56
+ };
57
+ if (options.tags?.length) query.tags = options.tags.join(',');
58
+ const res = await request<Breadcrumb[] | { breadcrumbs: Breadcrumb[] }>(this.ctx, '/v1/breadcrumbs/search', { query });
59
+ return Array.isArray(res) ? res : (res.breadcrumbs ?? []);
60
+ }
61
+
62
+ /** `GET /v1/breadcrumbs/{id}` */
63
+ async get(id: string): Promise<Breadcrumb> {
64
+ return request<Breadcrumb>(this.ctx, `/v1/breadcrumbs/${id}`);
65
+ }
66
+
67
+ /**
68
+ * `PATCH /v1/breadcrumbs/{id}` — optimistic-locking update.
69
+ *
70
+ * If `req.version` is wrong, the server returns 409. Pass
71
+ * `autoRetryConflict: true` to have the SDK refetch + retry once.
72
+ */
73
+ async update(
74
+ id: string,
75
+ req: UpdateBreadcrumbRequest,
76
+ opts: { autoRetryConflict?: boolean } = {},
77
+ ): Promise<Breadcrumb> {
78
+ const refetchBeforeRetry = opts.autoRetryConflict
79
+ ? async () => {
80
+ const fresh = await this.get(id);
81
+ return {
82
+ body: {
83
+ ...req,
84
+ version: fresh.version,
85
+ } satisfies UpdateBreadcrumbRequest,
86
+ };
87
+ }
88
+ : undefined;
89
+
90
+ const res = await request<BreadcrumbResponse | Breadcrumb>(this.ctx, `/v1/breadcrumbs/${id}`, {
91
+ method: 'PATCH',
92
+ body: req,
93
+ ...(refetchBeforeRetry
94
+ ? { maxConflictRetries: 2, refetchBeforeRetry }
95
+ : {}),
96
+ });
97
+ return 'breadcrumb' in (res as BreadcrumbResponse)
98
+ ? (res as BreadcrumbResponse).breadcrumb
99
+ : (res as Breadcrumb);
100
+ }
101
+
102
+ /** `DELETE /v1/breadcrumbs/{id}` — soft delete. */
103
+ async delete(id: string): Promise<void> {
104
+ try {
105
+ await request<void>(this.ctx, `/v1/breadcrumbs/${id}`, { method: 'DELETE' });
106
+ } catch (err) {
107
+ if (err instanceof ApiError && err.status === 404) return; // idempotent
108
+ throw err;
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Capabilities module — discover what the current workspace can do.
3
+ *
4
+ * In RCRT, capabilities are breadcrumbs tagged with `interpret:*`:
5
+ * - `interpret:executable` — tools (functions an agent can call)
6
+ * - `interpret:promptable` — agents (entities the user / other
7
+ * agents can `chat.send()` to)
8
+ * - `interpret:service-def` — connectable services (Gmail, Notion,
9
+ * etc.) with their OAuth metadata
10
+ * - `interpret:knowledge` — static knowledge bases attached to the
11
+ * workspace
12
+ *
13
+ * This module is a thin convenience layer over `breadcrumbs.queryByTags`
14
+ * that maps the `type` argument to the right tag.
15
+ */
16
+
17
+ import type { FetchContext } from './internal/fetch.js';
18
+ import { request } from './internal/fetch.js';
19
+ import type { Breadcrumb } from './types/breadcrumb.js';
20
+
21
+ export type CapabilityType = 'tools' | 'agents' | 'services' | 'knowledge';
22
+
23
+ const TAG_BY_TYPE: Record<CapabilityType, string> = {
24
+ tools: 'interpret:executable',
25
+ agents: 'interpret:promptable',
26
+ services: 'interpret:service-def',
27
+ knowledge: 'interpret:knowledge',
28
+ };
29
+
30
+ export interface ChattableAgent {
31
+ /** The id you pass to `chat.send({ target_agent: ... })`. */
32
+ id: string;
33
+ name: string;
34
+ description: string;
35
+ /** True when this agent has the `interface:chat-default` tag. */
36
+ isDefault: boolean;
37
+ }
38
+
39
+ export class CapabilitiesModule {
40
+ constructor(private readonly ctx: FetchContext) {}
41
+
42
+ /**
43
+ * List every capability of every type. Useful when surfacing the
44
+ * full toolbelt + agent roster to a debug / admin view.
45
+ */
46
+ async listAll(limit: number = 200): Promise<Breadcrumb[]> {
47
+ const res = await request<Breadcrumb[] | { breadcrumbs: Breadcrumb[] }>(
48
+ this.ctx,
49
+ '/v1/breadcrumbs',
50
+ { query: { tags: TAG_BY_TYPE.tools, limit } },
51
+ );
52
+ return Array.isArray(res) ? res : (res.breadcrumbs ?? []);
53
+ }
54
+
55
+ /** List capabilities of one specific type. */
56
+ async list(type: CapabilityType, limit: number = 200): Promise<Breadcrumb[]> {
57
+ const tag = TAG_BY_TYPE[type];
58
+ const res = await request<Breadcrumb[] | { breadcrumbs: Breadcrumb[] }>(
59
+ this.ctx,
60
+ '/v1/breadcrumbs',
61
+ { query: { tags: tag, limit } },
62
+ );
63
+ return Array.isArray(res) ? res : (res.breadcrumbs ?? []);
64
+ }
65
+
66
+ /**
67
+ * Convenience: agents the user can chat with directly.
68
+ *
69
+ * Filters `interpret:promptable` to those tagged `interface:chat`
70
+ * or `interface:chat-default`. Falls back to all promptables when
71
+ * no agents have a chat interface tag (older deploys).
72
+ */
73
+ async getChattableAgents(limit: number = 50): Promise<ChattableAgent[]> {
74
+ const promptables = await this.list('agents', limit);
75
+ const chatAgents = promptables.filter((bc) =>
76
+ bc.tags?.some(
77
+ (t) => t === 'interface:chat' || t === 'interface:chat-default',
78
+ ),
79
+ );
80
+ const source = chatAgents.length > 0 ? chatAgents : promptables;
81
+ return source.map<ChattableAgent>((bc) => {
82
+ const content = (bc.content ?? {}) as Record<string, unknown>;
83
+ const id = (bc as { name?: string }).name ?? bc.id;
84
+ const name =
85
+ (content.name as string | undefined) ??
86
+ (bc as { title?: string }).title ??
87
+ id;
88
+ const description = (content.description as string | undefined) ?? '';
89
+ const isDefault = !!bc.tags?.includes('interface:chat-default');
90
+ return { id, name, description, isDefault };
91
+ });
92
+ }
93
+ }