Skip to main content
Version: 3.1

App Database Files

A per-App database (aktorAppDatabase()) ships with a BLOB-backed files table for storing binary content alongside structured rows. Use appFileUrl() to hand a downloadable URL back to a chat, a tool result, or a markdown message — the platform serves the bytes directly from the App's database.

Prerequisite: a per-App database

The files table lives in the per-App SQLite database opened by aktorAppDatabase(). See Agent DB for general database usage and how to address multiple named databases. The legacy aktorDatabase({id}) form does not work with appFileUrl() because the helper relies on the AppInstance context that aktorAppDatabase() carries.

Initialize the file table

Run aktorInitVectorDB once during reaktor setup. It creates the files table together with the rest of the vector schema:

import { aktorAppDatabase } from "@operaide/database";
import { aktorInitVectorDB } from "@operaide/vector";

const db = aktorInitVectorDB({ client: aktorAppDatabase({}) });

aktorVektorDatabase() runs the same initializer as a convenience wrapper, but currently still binds to the legacy explicit-id form via aktorSetting(). For new apps, prefer the manual aktorInitVectorDB({ client: aktorAppDatabase({}) }) pattern shown above.

The schema:

CREATE TABLE files (
file_id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
source_path TEXT,
url TEXT,
extension TEXT,
mime_type TEXT,
size_bytes INTEGER,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
file_hash TEXT NOT NULL,
binary_data BLOB
);

The platform's file handler reads filename, mime_type, and binary_data to serve the response.

Store a file

aktorStoreFileWithContent computes a SHA-256 hash, deduplicates against existing rows, and writes the BLOB:

import { aktorStoreFileWithContent } from "@operaide/vector";

const stored = aktorStoreFileWithContent({
client: db,
filename: "invoice_Q1.pdf",
content: pdfBytes, // Uint8Array
});
// stored.file_id is the handle you pass to appFileUrl()

For full control over hashing or metadata, use the lower-level aktorCreateFile from the same package.

Build a download URL

import { appFileUrl } from "@operaide/aktor";

const url = appFileUrl({
fileId: "file_1733...",
filename: "invoice_Q1.pdf", // optional, cosmetic
});
// '/databases/{orgId}:{instanceName}/files/file_1733.../invoice_Q1.pdf'

fileId drives the lookup. filename is appended to the URL but ignored on the server. It surfaces in browser status bars, copy-paste, bookmarks, and Save-As dialogs before any response header is read.

If the file lives in a named database (aktorAppDatabase({ name })), pass dbName so the URL targets the right file:

const url = appFileUrl({
fileId: "file_1733...",
filename: "invoice_Q1.pdf",
dbName: "archive",
});
// '/databases/{orgId}:{instanceName}:archive/files/file_1733.../invoice_Q1.pdf'

appFileUrl() throws when called outside an AppInstance context (standalone reaktors, unit tests).

Hand the URL to a chat

The op-demo-database reference app exposes downloads to an LLM through a small tool aktor. The reaktor stores files in the database, then the tool turns a file_id from a query result into a clickable link the model can emit:

import { appFileUrl } from "@operaide/aktor";

async function generateFileUrl(p: { client: Client; fileId: string }) {
const result = await p.client.execute({
sql: "SELECT filename, mime_type, size_bytes FROM files WHERE file_id = ?",
args: [p.fileId],
});
if (result.rows.length === 0) {
return { error: "File not found", fileUrl: null };
}
const row = result.rows[0];
return {
fileUrl: appFileUrl({
fileId: p.fileId,
filename: (row.filename as string | null) ?? undefined,
}),
};
}

Wrapped as an LLM tool, the model returns markdown links of the form [invoice_Q1.pdf](/databases/...), which the chat UI renders as downloads.

URL contract (platform-internal)

The handler accepts:

/databases/{orgId}:{instanceName}[:{dbName}]/files/{fileId}[/{displayFilename}]

Without :{dbName} the URL targets the App's default database. Apps must not construct these strings. Use appFileUrl() so the URL shape stays an internal platform concern. The pattern is documented here for tracing and debugging only.

warning

The handler does not enforce an authentication check. Anyone who knows the orgId, instanceName, and fileId can fetch the bytes. Treat fileId as a capability and avoid embedding download URLs in untrusted contexts.

Common mistakes

  • Constructing the URL by hand. The path layout, the : separator between orgId and instanceName, and the encoding of the optional filename are platform-owned. Use appFileUrl().
  • Selecting binary_data (or any BLOB column) into chat or tool output. BLOB rows balloon traces and AI-call payloads. Project explicit columns and serve bytes only through appFileUrl().
  • Using aktorDatabase({id}) for new apps. Without an AppInstance context, appFileUrl() throws. Start with aktorAppDatabase().
  • Skipping the schema init. Without aktorInitVectorDB (or aktorVektorDatabase), the files table does not exist and inserts fail.

For the broader file ingestion and retrieval pipeline (documents, chunks, vectors), see Vectorization Functions.