Skip to content

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

FieldTypeNotes
idId
beingIdId
providerConnectionProvider
labelstring
status'connected' | 'pending' | 'error' | 'disconnected'
metaJsonoptional · default {} · non-secret info (email address, root path, bot username)
lastSyncAtISODate | nulloptional · default null
createdAtISODate

FileScope

'user' | 'project' | 'upload'

FileEntry

FieldTypeNotes
idId
beingIdId
pathstringabsolute on disk (server-side only) or project-relative
namestring
extstring
sizenumber
mtimeISODate
isDirbooleanoptional · default false
summarystring | nulloptional · default null
scopeFileScope
projectIdId | nulloptional · default null

LlmCall

FieldTypeNotes
idId
beingIdId | null
modelstring
tierstring
promptTokensnumber
completionTokensnumber
costUsdnumber
purposestring
durationMsnumber
createdAtISODate

LlmUsage

FieldTypeNotes
todayUsdnumber
totalUsdnumber
callsnumber
byModel{ model, calls, usd }[]
recentLlmCall[]

TwinActivityKind

'learned' | 'read_email' | 'indexed_file' | 'browsed' | 'sent' | 'scheduled' | 'thought'

TwinActivity

FieldTypeNotes
idId
beingIdId
kindTwinActivityKind
summarystring
atISODate

SyncStatus

'idle' | 'running' | 'done' | 'error'

SyncState

FieldTypeNotes
statusSyncStatus
startedAtISODate | null
finishedAtISODate | null
errorstring | null
detailRecord<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.

FieldTypeNotes
namestring (min 1, max 40)
labelstring (min 1, max 80)
secretbooleanoptional · default true
helpstring (max 200)optional · default ''
placeholderstring (max 160)optional · default ''
requiredbooleanoptional · 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.

FieldTypeNotes
idConnectionProvider
titlestring (min 1, max 80)
descriptionstring (max 600)optional · default ''
iconstring (max 8)optional · default '🔌'
origin'builtin' | 'plugin'
pluginIdstring (max 120) | nulloptional · default null
authProviderAuth
grantsstring (max 160)[]optional · default []
skillsstring (max 80)[]optional · default []
cadenceMsnumber (safeint, ≥ 0, ≤ 2592000000) | nulloptional · default null
syncablebooleanoptional · default false

ProviderSyncItem

One thing a provider’s sync brought back. The host turns each into a memory the twin can recall.

FieldTypeNotes
idstring (min 1, max 200)
titlestring (max 300)optional · default ''
textstring (min 1, max 8000)
urlstring (max 2000) | nulloptional · default null
atstring (max 40) | nulloptional · 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.

FieldTypeNotes
itemsProviderSyncItem[]optional · default []
cursorstring (max 400) | nulloptional · default null
notestring (max 300)optional · default ''

TwinProviderStatus

One row per provider in GET /api/twin/status.

FieldTypeNotes
providerConnectionProvider
titlestring (max 80)optional · default ''
iconstring (max 8)optional · default '🔌'
origin'builtin' | 'plugin'optional · default 'builtin'
syncablebooleanoptional · default false
configuredboolean
connectedboolean
connectionIdId | null
labelstring
metaJsonoptional · default {}
lastSyncAtISODate | null
syncSyncState
hintstring | nulloptional · default null

TwinStatus

FieldTypeNotes
llm{ configured: boolean }
counts{ memories: number; files: number; activity: number; connections: number }
providersTwinProviderStatus[]

TelegramPairing

GET /api/connections/telegram/pairing

FieldTypeNotes
configuredboolean
pairedboolean
codestring | null
expiresAtstring | null
botUsernamestring | null
deepLinkstring | null
chatTitlestring | 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.

FieldTypeNotes
availableboolean
reasonstring | nulloptional · default null
providerstring
requestedstring
modelstring
baseUrlstring
leavesDeviceboolean
destinationstring
costsMoneyboolean
usdPerMinutenumber
consentKeystring
consentedboolean
consentedAtISODate | nulloptional · default null
notestring | nulloptional · default null

SttConsentRequest

POST /api/twin/voice/stt/consent — agree to the destination this exact key names, and no other.

FieldTypeNotes
consentKeystring (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.

FieldTypeNotes
textstring
languagestring | nulloptional · default null
audioSecondsnumber | nulloptional · default null
bytesnumber
formatstring
modelstring
providerstring
leavesDeviceboolean
destinationstring
costUsdnumber
durationMsnumber