Twin, profile & connections
Ingestion, connected providers and the files your Twin indexes.
Every schema below is exported from @novaterra/contracts and validated on both sides of the wire. Source: packages/contracts/src/twin.ts.
ConnectionProvider
A provider id. This was z.enum(BUILTIN_PROVIDERS), and that enum was the reason nobody outside this monorepo could give a Twin a new way to reach the world: a provider had to exist here, in a switch in the connect route, and in a second switch in the twin tick. What decides which providers exist is now the host’s provider registry; what this schema decides is only what a provider id may LOOK like, so a row written by a plugin nobody in this repo has read still round-trips. The shape is chosen so that every id already on an owner’s disk parses unchanged — gmail, telegram, files, browser, whatsapp are all bare lower-snake segments — and it is proved against a real pre-change database in tests/twin/connection-provider-compat.test.ts rather than assumed. A plugin’s provider is namespaced by the host as <plugin namespace>.<provider>, which is the only reason a dot is legal here.
string (regex)
Connection
| Field | Type | Notes |
|---|---|---|
id | Id | |
beingId | Id | |
provider | ConnectionProvider | |
label | string | |
status | 'connected' | 'pending' | 'error' | 'disconnected' | |
meta | Json | optional · default {} · non-secret info (email address, root path, bot username) |
lastSyncAt | ISODate | null | optional · default null |
createdAt | ISODate |
FileScope
'user' | 'project' | 'upload'
FileEntry
| Field | Type | Notes |
|---|---|---|
id | Id | |
beingId | Id | |
path | string | absolute on disk (server-side only) or project-relative |
name | string | |
ext | string | |
size | number | |
mtime | ISODate | |
isDir | boolean | optional · default false |
summary | string | null | optional · default null |
scope | FileScope | |
projectId | Id | null | optional · default null |
LlmCall
| Field | Type | Notes |
|---|---|---|
id | Id | |
beingId | Id | null | |
model | string | |
tier | string | |
promptTokens | number | |
completionTokens | number | |
costUsd | number | |
purpose | string | |
durationMs | number | |
createdAt | ISODate |
LlmUsage
| Field | Type | Notes |
|---|---|---|
todayUsd | number | |
totalUsd | number | |
calls | number | |
byModel | { model, calls, usd }[] | |
recent | LlmCall[] |
TwinActivityKind
'learned' | 'read_email' | 'indexed_file' | 'browsed' | 'sent' | 'scheduled' | 'thought'
TwinActivity
| Field | Type | Notes |
|---|---|---|
id | Id | |
beingId | Id | |
kind | TwinActivityKind | |
summary | string | |
at | ISODate |
SyncStatus
'idle' | 'running' | 'done' | 'error'
SyncState
| Field | Type | Notes |
|---|---|---|
status | SyncStatus | |
startedAt | ISODate | null | |
finishedAt | ISODate | null | |
error | string | null | |
detail | Record<string, Json> | optional · default {} |
ProviderAuthField
One field a provider asks a human to fill in to connect. secret is the load-bearing flag, and it decides where the value goes rather than how it is drawn: a secret field is encrypted at rest by core/crypto.ts and is NEVER handed to the code that owns the provider, while a non-secret field is mirrored into the connection’s public meta and is the only thing a third-party provider’s sync ever sees. A feed URL is not a secret; an API key is.
| Field | Type | Notes |
|---|---|---|
name | string (min 1, max 40) | |
label | string (min 1, max 80) | |
secret | boolean | optional · default true |
help | string (max 200) | optional · default '' |
placeholder | string (max 160) | optional · default '' |
required | boolean | optional · default true |
ProviderAuth
How a provider is connected, in enough detail that a connect flow can be rendered for a provider the web app has never heard of. This is the deliverable for OAuth2, not a working OAuth client: “this needs OAuth2, start it here, these scopes” is a descriptor a UI can act on; a half-working integration for a service nobody can log into here is not.
{ kind: ‘oauth2’; startPath: string (min 1, max 200); scopes: string (max 200)[] } | { kind: ‘fields’; fields: ProviderAuthField[] } | { kind: ‘path’; field: string (min 1, max 40); label: string (max 80); help: string (max 200) } | { kind: ‘webhook’; path: string (min 1, max 120); verify: ‘none’ | ‘hmac-sha256’ | ‘shared-secret’ } | { kind: ‘none’ }
ProviderDescriptor
A registered provider, as GET /api/connections/providers reports it.
| Field | Type | Notes |
|---|---|---|
id | ConnectionProvider | |
title | string (min 1, max 80) | |
description | string (max 600) | optional · default '' |
icon | string (max 8) | optional · default '🔌' |
origin | 'builtin' | 'plugin' | |
pluginId | string (max 120) | null | optional · default null |
auth | ProviderAuth | |
grants | string (max 160)[] | optional · default [] |
skills | string (max 80)[] | optional · default [] |
cadenceMs | number (safeint, ≥ 0, ≤ 2592000000) | null | optional · default null |
syncable | boolean | optional · default false |
ProviderSyncItem
One thing a provider’s sync brought back. The host turns each into a memory the twin can recall.
| Field | Type | Notes |
|---|---|---|
id | string (min 1, max 200) | |
title | string (max 300) | optional · default '' |
text | string (min 1, max 8000) | |
url | string (max 2000) | null | optional · default null |
at | string (max 40) | null | optional · default null |
ProviderSyncResult
What a provider’s sync entry point returns. The HOST validates a plugin’s return value against this, not the plugin’s own declared schema: a manifest is written by the same person as the code it describes, so trusting it to bound what comes back would be trusting the thing being checked.
| Field | Type | Notes |
|---|---|---|
items | ProviderSyncItem[] | optional · default [] |
cursor | string (max 400) | null | optional · default null |
note | string (max 300) | optional · default '' |
TwinProviderStatus
One row per provider in GET /api/twin/status.
| Field | Type | Notes |
|---|---|---|
provider | ConnectionProvider | |
title | string (max 80) | optional · default '' |
icon | string (max 8) | optional · default '🔌' |
origin | 'builtin' | 'plugin' | optional · default 'builtin' |
syncable | boolean | optional · default false |
configured | boolean | |
connected | boolean | |
connectionId | Id | null | |
label | string | |
meta | Json | optional · default {} |
lastSyncAt | ISODate | null | |
sync | SyncState | |
hint | string | null | optional · default null |
TwinStatus
| Field | Type | Notes |
|---|---|---|
llm | { configured: boolean } | |
counts | { memories: number; files: number; activity: number; connections: number } | |
providers | TwinProviderStatus[] |
TelegramPairing
GET /api/connections/telegram/pairing
| Field | Type | Notes |
|---|---|---|
configured | boolean | |
paired | boolean | |
code | string | null | |
expiresAt | string | null | |
botUsername | string | null | |
deepLink | string | null | |
chatTitle | string | null |
NodeSttStatus
Where the node would send a recording, and whether this being has agreed to it. The browser’s SpeechRecognition is not a local API — Chrome streams the microphone to Google and there is no flag that stops it (apps/web/src/voice/listen.ts says so at length). The node has its own transcribe tier now, so there is a second answer, and this is the shape that lets a screen tell the truth about it: not “voice is private” but the actual provider, the actual model, and whether the audio can leave the machine at all. leavesDevice is the field everything else hangs off, and it is NOT provider !== 'local'. A tier pointed at the owner’s own hardware with LOCAL_LLM_FALLBACK=openrouter will send the recording to OpenRouter the moment the home box does not answer, so it leaves — invariant 5 in CLAUDE.md, spelled as a boolean rather than left for a reader to derive.
| Field | Type | Notes |
|---|---|---|
available | boolean | |
reason | string | null | optional · default null |
provider | string | |
requested | string | |
model | string | |
baseUrl | string | |
leavesDevice | boolean | |
destination | string | |
costsMoney | boolean | |
usdPerMinute | number | |
consentKey | string | |
consented | boolean | |
consentedAt | ISODate | null | optional · default null |
note | string | null | optional · default null |
SttConsentRequest
POST /api/twin/voice/stt/consent — agree to the destination this exact key names, and no other.
| Field | Type | Notes |
|---|---|---|
consentKey | string (min 1, max 200) |
NodeTranscript
What comes back from POST /api/twin/voice/transcribe. destination and leavesDevice are repeated here rather than left to the status route on purpose: this is the record of where THIS recording went, answered by the same request that sent it, so a screen never has to pair a transcript with a status it fetched at some other moment.
| Field | Type | Notes |
|---|---|---|
text | string | |
language | string | null | optional · default null |
audioSeconds | number | null | optional · default null |
bytes | number | |
format | string | |
model | string | |
provider | string | |
leavesDevice | boolean | |
destination | string | |
costUsd | number | |
durationMs | number |