Skip to content

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.

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.

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.

Google Drive, OneDrive, and an MCP server whose authentication is masks use a person’s own account. Nobody enters a secret into xixo.

  1. Attach it. It starts out not connected.
  2. Connect it. The browser goes to masks, which asks for the person’s consent, signs them in to the provider if needed, and returns.
  3. From then on it syncs and answers requests, with nobody signed in.
  4. 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>/connect needs xixo:resources:command, and the caller has to be able to see the resource.
  • /connect/callback refuses a state that 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_at and raises Resource::Unusable, which stops a sync without retrying it. When masks does not answer, xixo raises Resource::Failed, which is retried as usual. A successful check or sync clears needs_connect_at.

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.

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.

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.

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.