Resources
A resource is one instance of a type, such as a bucket, a directory, a mailbox, an inference endpoint, or an MCP server. Its key is unique within its tenant and type. The resource reference lists what each type needs and what it can do. This page covers what all types share.
What a type declares
Section titled “What a type declares”A type declares what it serves with three class macros:
serves |
its capabilities: storage, inference, browser, fetch, search, tools, or integration |
accepts |
the mime patterns it will store, such as */* |
up_to |
the largest file it will take, in bytes, or no limit when it is unset |
browser is the web type, which renders pages. fetch is the curl type, which reads a page’s
text. search is a web search API. Types such as git, IMAP, RSS, and CalDAV declare no capability,
and xixo reads them by syncing.
Every save copies the declaration into the serving jsonb column. A question such as “which active
stores accept a 4 GB video” is then one SQL query, and xixo does not need to load every resource to
answer it. The copy is only as current as the last save. bin/rails xixo:resources, which the
production entrypoint runs before the server starts, saves every row again in every tenant and then
reconciles the declared resources.
A tenant has at most one default storage and one default inference resource, and only a resource
that serves the matching capability can hold either one. Making a resource the default clears the
flag on the previous one. Snapshots go to the default storage unless the web resource names
another, an upload that nothing placed goes there, and inference uses the default when more than one
resource serves a role.
Where they come from
Section titled “Where they come from”config/resources.yml is ERB, keyed by environment, and reads every host and secret from the
environment. The shipped file declares a files filesystem resource as the default storage.
Resource.declare! reconciles it in each tenant. It creates or updates each declared key and never
deletes. A resource removed from the file stays attached, so nobody using it loses it.
In development, db/seeds.rb attaches a database store, an S3 bucket in MinIO, a web resource,
and an Ollama inference resource. See the quickstart.
People attach everything else in the browser. A type that can be attached returns an attaching
description, with a label and a list of fields, and the form is rendered from it.
Resource::Settings reads the submitted values against those fields, for both the file and the
form. It drops any value the type does not declare and refuses a missing required field. Each value
goes into details, or into credentials when the field is a secret. Credentials are encrypted at
rest, and the audit log records which fields were set but never their values. Attaching runs a first
check and reports the result. See attaching a resource.
A resource is changed through the same form. Its type and key stay the same. Its name and fields are read against the type again. A secret left empty keeps its current value, and a field the type no longer asks for is removed along with its value. The form reads back only the values held in plain text, plus the names of the encrypted fields that have a value. xixo checks and audits a change the same way it checks and audits attaching.
A filesystem resource can be attached only when XIXO_FILESYSTEM_ROOTS names directories. Each
tenant gets its own directory under each root, named for its subdomain and created on first use. A
resource’s root has to be inside one of those directories. The configured root itself is shared by
every tenant on the server, so no resource can point at it. A relative root is resolved against the
tenant’s directory under the first root. The shipped files resource, with root ., lives there.
xixo checks the real path of a root, so it refuses a symlink that leads out of the tenant’s
directory, and a write never follows a symlink.
Resources connected through masks
Section titled “Resources connected through masks”Google Drive, OneDrive, and an MCP server whose authentication is masks use a person’s own
account. Nobody enters a secret into xixo.
- Attach it. It starts out not connected.
- Connect it. The browser goes to masks, which asks for the person’s consent, signs them in to the provider if needed, and returns.
- From then on it syncs and answers requests, with nobody signed in.
- If the person revokes xixo’s access in masks, the resource shows Reconnect and its syncs stop until somebody reconnects it.
masks keeps the provider’s tokens. xixo keeps a delegation, which is the connection and a secret encrypted in the resource’s credentials. When the last provider token expires, xixo exchanges the secret for a new one.
xixo enforces these rules:
/resources/<id>/connectneedsxixo:resources:command, and the caller has to be able to see the resource./connect/callbackrefuses astatethat this browser did not start, a callback that somebody else already finished, and a connection that masks made for a different person.- Only one worker at a time renews a resource’s token, under a row lock, because masks revokes a secret that is used twice. xixo keeps every rotated secret.
- When a provider API refuses the token, xixo gets one new token and tries once more.
- When masks refuses, xixo sets
needs_connect_atand raisesResource::Unusable, which stops a sync without retrying it. When masks does not answer, xixo raisesResource::Failed, which is retried as usual. A successful check or sync clearsneeds_connect_at.
Only mine, or everyone’s
Section titled “Only mine, or everyone’s”The person who attaches a resource chooses who can use it. Everyone here is the default.
Only me records the person’s subject as owner_subject. From then on the resource is hidden from
everybody else: from the resources query, from every mutation that names a resource, from the
resource and feed tools, from the connect route, and from the MCP tools a personal MCP server
offers. A type connected through masks starts as Only me.
What a personal resource syncs is cataloged for the whole tenant, like anything else. A personal resource is never the default for uploads or questions. An agent working on a feed reaches it only in a run its owner started. See whose resources a run reaches.
Syncing
Section titled “Syncing”A type that implements each_page can sync. A type that does not, such as web, can neither sync
nor have a schedule. sync_interval is at least one minute, and ScheduleSyncsJob checks every
minute for resources that are due.
Starting a sync claims the resource with a single conditional update, so two workers cannot both
start one, and opens a run of kind sync. The job walks the resource page by page with
job-iteration. It records a reference for each object, analyzes any
feed that needs it, and resumes at its cursor after a deploy. A
Resource::Failed error gets five attempts before the run fails. When the sync finishes, xixo
clears the claim and schedules the next sync on the original interval, skipping any times that were
missed. A failed sync also clears the claim, but does not record the resource as synced. If the
worker running a sync dies, the claim stays for six hours. After that xixo treats the sync as
abandoned, and another sync can start.
A run can be cancelled, and it stops when it passes its deadline.
Resource::Walk decides which kind of walk a sync does. A type that can ask its source for only
what changed, such as git, IMAP, GitHub, or OneDrive, keeps a checkpoint in sync_state. The
checkpoint is the commit it reached, the last message it saw together with the mailbox’s
UIDVALIDITY, the time the last walk began minus five minutes, or the delta link Microsoft Graph
handed back. The next sync walks only what changed since the checkpoint, as long as a full walk
finished within the last day. Otherwise it walks everything. It also walks everything when the
checkpoint cannot be used, for example because history was rewritten, the mailbox was renumbered,
or Microsoft Graph expired the delta link. A walk saves its new checkpoint only when it finishes. A cancelled, failed,
or dry-run sync leaves the previous checkpoint in place, and a walk resumed after a deploy keeps the
checkpoint it started with. walked_at records when a full walk last finished without missing
anything.
Reaching through another resource
Section titled “Reaching through another resource”A resource can name a transport in its via column. A transport is a resource that serves
transport. It lists the addresses it reaches in covers, and reach! can rewrite a URL before it
is dialed. The shipped transport is tailnet, which covers the Tailscale ranges 100.64.0.0/10 and
fd7a:115c:a1e0::/48. See reach a tailnet.
A resource with a via connects only to addresses its transport covers. The address check runs as
it always does, with the transport’s ranges in place of the public internet, so a public address is
refused as firmly as a loopback one. XIXO_ALLOW_PRIVATE_FETCH and the _ORIGINS lists do not widen
it. Where xixo resolves a name itself, it connects to the address it checked. An s3 resource also
checks the address of the peer that answers, since the AWS SDK resolves names on its own.
These types can name a via: webdav, caldav, carddav, s3, mcp, search, curl, rss,
imap, git, and openai-compatible. Every other type refuses one. A git resource behind a
transport takes only an http or https URL, since the address check does not apply to ssh.
Checking a resource with a via checks the transport first. If the transport fails, the resource
fails with <key> is reached through <transport>, which is down, followed by the transport’s error.
via is set by the via argument of attachResource and updateResource, by the “Reached through”
field when a resource is attached or changed in the app, or by a via key in config/resources.yml.
The validations refuse a via that does not serve transport, that belongs to another tenant, that points at the resource itself, that forms a
loop, or that is more than four hops from a resource that answers. The foreign key is composite:
(via_id, tenant_id) references (id, tenant_id), so the database refuses a link to another
tenant’s resource even if the application does not. A transport cannot be archived while an active
resource uses it.
Health, internal stores, and archiving
Section titled “Health, internal stores, and archiving”A check calls the type’s check! and records checked_at and check_error. A resource is healthy
when it has been checked and the last check raised no error. ScheduleChecksJob runs every hour. It
finds resources that were never checked or not checked in six hours, and checks each one in its own
job, so a resource that stopped answering shows it before a sync or a tool call reaches it. It skips
a resource that is syncing, since the sync tests it better. Checking an MCP server asks it again for
its tools, so tools it adds or removes reach the proxy within six hours.
xixo keeps two stores for itself: children, for what is extracted from a file, and derived, for
previews and thumbnails. Both are database resources, and neither is ever offered as a place for an
upload. Neither is listed or reachable by key through GraphQL or MCP. Neither can be archived,
synced, scheduled, or made a default, because xixo writes into them regardless of those settings.
Archiving sets archived_at and deletes nothing. An archived resource is left out of capability
queries, placement, the defaults, and the sync schedule. Restoring it clears the timestamp.