Connectors
The connector port: filesystem-like sources with declared capabilities and closed config schemas.
A source connector contributes a new source kind to the URI-addressed
filesystem. Once registered, admins can create sources of that kind, and the
platform's filesystem tools, sync runners, and UI route through the connector
transparently — the URI is the unit of authorization, not the connector kind.
The runtime contract is ConnectorPort, versioned as
CONNECTOR_PORT_VERSION = 4 and bumped on any breaking change. Version 4 adds connector-owned isRuntimeActive?() and factory projectId?/packageState() access. A runtime-gated connector answers enabled-auto state for its exact binding without starting its runtime.
The capability set
Capabilities form a closed list. list and read are mandatory; every other
capability gates an optional method on the port:
| Capability | Method | Purpose |
|---|---|---|
list | list(uri) | Directory listing (required) |
read | read(uri) | File content (required) |
stat | stat(uri) | Existence plus cheap metadata (isDirectory, size?, modifiedAt?) — no content. It resolves null when the connector reached its backing store and the artifact is not there, and THROWS with the same error shapes read uses for anything else, so a caller can tell "gone" from "could not ask". Optional: where a kind does not declare it, a caller falls back to whatever it did before, so nothing is lost. Neither list nor read substitutes for it — a listing answers [] for a missing AND an unreadable parent on some kinds, and reading a whole file to learn that it exists is the cost this removes |
write | write(uri, content, mimeType?) | Create/overwrite — accepts string or Uint8Array |
delete | delete(uri) | Remove a file |
move | move(fromUri, toUri) | Rename/move |
mkdir | mkdir(uri) | Create an empty directory (recursive, mkdir -p semantics); a no-op when it already exists |
rmdir | rmdir(uri) | Remove an empty directory shell. This is the low-level primitive: a recursive folder delete is orchestrated one level up — every contained file is unlinked or tombstoned first, then the empty shells drop bottom-up — so it never blindly erases a populated tree |
exec | exec(request, { session, signal? }) | Run a command in the source |
execDetached | the detached facet — start, status, output, kill, release | Run a command that OUTLIVES the call. Requires exec. All five verbs are required: a declared capability with a missing verb would be a run the platform can start and never stop |
scanDelta | scanDelta(request) | Changed-files-since-checkpoint, for sync. request.signal carries cooperative cancellation — check it at natural batch boundaries (e.g. per directory). request.excludeGlobs SHOULD be pruned during the walk (excluded directories never descended into) so a repo-sized source does not exhaust its scan cap on node_modules/.git before reaching late-sorted included trees. request.includeInventory asks for the complete file set of the same scan beside the changed one, returned as inventory — sync needs both "what changed" and "what is there now", and answering them separately walks the tree twice on every incremental pass. Honouring it is optional: omit inventory and the caller falls back to a second scan, so an existing connector needs no change. When you do answer it, files must be a subset of inventory, and the one checkpoint (and truncated) covers both |
walk | walk(rootUri, opts?) | Recursive enumeration with excludes |
count / peekCount | count(rootUri), peekCount(rootUri) | Async exact count / sync cached estimate |
scanContent | scanContent(rootUri, query, opts?) | Content search with match previews |
subscribeEvents | subscribeEvents(rootUri, listener, opts?) | Change events (created/modified/deleted/moved/overflow — overflow means event delivery can no longer be trusted and the consumer must schedule a full reconcile; an overflow MAY carry an optional reason — watcher_error, watcher_exhausted or watcher_restarted — and an optional code, the underlying errno NAME, so the cause can be logged and an incident attributed. Both are closed vocabularies: never free-form text, and never a path). opts.excludeGlobs pushes the consumer's exclude set down to the watcher layer so excluded trees cost no OS watch resources |
resolveOsUri | resolveOsUri(uri) | Host-OS absolute path for a URI |
The instance's capabilities() set must exactly match the
capabilities[] declared in the manifest — the loader cross-checks at
instantiation and throws ConnectorCapabilityMismatch on any divergence. A
connector can never claim more (or less) at runtime than it declared. The check
covers execDetached against the detached facet in both directions, so a
capability without the facet and a facet without the capability are the same
error.
Detached runs. detached.start(request, context) is a second exec ENTRY: it
runs under the caller's session and the platform enforces the kind's declared
entry feature(s) on it exactly as it does on exec. The other four verbs are
identity-free transport keyed on the run id the provider returned —
status(runId, waitMs?) (a bounded long poll is allowed), output(runId, since), kill(runId, actor?) and release(runId). That asymmetry is
deliberate: a caller who has since lost the entry feature must still be able to
read and stop a run they own, and ownership is the platform's stored record, not
the feature. actor is informational for your own audit row, never something to
gate on. A kill is an acknowledgement — report the real end through status.
Three contract obligations deserve emphasis:
- Binary reads.
ConnectorReadResult.bytesis optional base64 raw bytes, emitted for binary MIME types (image/*,video/*,application/pdf) when the size fits the connector's per-MIME inline cap. Over the cap, leavebytesunset and surface a short text marker so the model still knows what was skipped. Whether bytes reach the model is gated downstream on the active model's image/video capability — connectors never peek at the runtime. resolveOsUrinever fabricates. A connector whose backing store lives on the host must return the real host-side path. One that has no host path at all has two honest answers, and it must pick one deliberately: throwOsUriUnresolvableError— the UI and the vector store propagate the structured error rather than serving a stale fallback — or, when the store is a sandbox with its own internally-meaningful absolute paths, return that internal path. Container-backed desktop sources take the second route: their container path is the true coordinate, and the platform's host/container translators only re-map host-anchored kinds. What is never acceptable is inventing a plausible-looking host path.- Containment is the connector's own job. A connector that maps a relative
path onto a filesystem root must resolve it through the real filesystem, never
a
path.resolve+startsWithstring check: a symlink planted inside the root serves whatever it points at, andstat/readFilefollow it. The kernel ships the primitives, all from@neuralis/package-system/paths:resolveContainedchecks lexically, thenrealpaths both sides, and answers a fixed error code rather than a path;openContainedHandlereads through the descriptor it opened and typed, so check and use are the same open;listContainedis areaddiron the already-resolved path and carries no such guarantee. The capability mirror above is cross-checked by the loader; nothing checks this — a connector owns its own root.
Optional liveness probe
probeConnection?(): Promise<{ connected, reason? }> is an optional, non-capability
method (it reports health, not a data operation). When present, the platform calls
it to tell whether the connector can currently reach its backing store, and dims a
disconnected source in the UI (icon + name) and marks it offline in the agent's
runtime view — without ever blocking access to already-synced content. reason is a
fixed enum (runtime_offline, fs_unreachable, fs_permission, store_unreachable)
so the signal stays parseable and never leaks a raw filesystem path. Keep the probe
cheap; network-backed connectors should return a cached reachability flag rather than
blocking on a per-call request.
Error taxonomy
Throw the typed errors from @neuralis/package-system/contracts — the
filesystem tools and the admin UI depend on the taxonomy, not on message
strings:
| Error | Meaning |
|---|---|
ConnectorNotFoundError | No connector registered for the kind |
ConnectorOrphanError | A source config references a kind whose package is gone — admin sees a "remove source" call to action |
ConnectorCapabilityMissing | Caller used a capability the connector does not advertise |
ConnectorRuntimeError | Wraps a connector-thrown exception with source + operation context |
ConnectorRootAccessError | Abstract base for ROOT_MISSING / ROOT_PERMISSION_DENIED on the source root — surfaced as a structured warning instead of an empty folder |
ConnectorTrustRejected | An untrusted package tried to contribute a source connector |
ConnectorCapabilityMismatch | Declared vs. runtime capability sets differ |
Registering a source kind
A connector ships as a connectors[] entry with type: "source" in the
manifest, plus a factory in the package's compiled code:
{
"id": "my-notion",
"type": "source",
"kind": "notion",
"transport": "in-process",
"name": "Notion Workspace",
"category": "api",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"workspaceId": { "type": "string", "minLength": 1 },
"apiKey": { "type": "string", "minLength": 1 }
},
"required": ["workspaceId", "apiKey"]
},
"capabilities": ["list", "read", "write", "walk", "scanContent"],
"defaultPermissions": { "default": { "read": true, "write": true, "exec": false } },
"defaultScope": "project",
"scopeSelectable": true,
"allowedScopes": ["project", "user", "agent"],
"connectorFactory": "./dist/connectors/NotionConnector.js#createNotionConnector"
}configSchemais a closed JSON Schema (draft-07,additionalProperties: false) that the admin source-creation form renders and the registry validates configs against. Two things to know before you lean on it: the checker is a hand-rolled subset — object properties,required, types, enum and basic string/number constraints — not a full draft-07 engine, so exotic keywords are simply ignored. AndadditionalProperties: falseis what makes it closed: omit it and unknown config keys are accepted silently, which is how a typo'd source config reaches your factory as a missing field instead of a validation error. Declare it explicitly.- Secrets do not live in
configSchema. A source connector that needs an API key declares it once in the package'sneuralis.credentials[]and reads it at runtime from theCredentialResolverthe loader injects intoinit(ctx)—ctx.credentials.resolveCredential(id, { projectId }). The factory closes over that port. There is no schema-property split and no credentials map on the factory context; one declaration vocabulary, one encrypted store. See Credentials. connectorFactoryis amodulePath#exportNamereference. The loader dynamic-imports the MODULE once, when the kind is registered, and calls the FACTORY once per source binding. An installed package's manifest is read when it loads at boot; aconnectors[]entry added later appears after the deployment's package-swap loop. A project_packages/drop is re-loaded on scan and on rescan.defaultScope/scopeSelectable/allowedScopescontrol whether sources of this kind are project-, user-, or agent-owned; the validator requiresallowedScopesto include the default and forces project scope when selection is disabled.defaultPermissionsseeds the source's permission tree — the same hierarchical shape evaluated by URI policies.runtimeGated(optional, defaultfalse) marks a connector whose availability depends on a runtime that can be running or stopped — for example a container-backed desktop source. When set, the platform reads the configured connector'sisRuntimeActive()member and dims its row in the file tree while the runtime is down, without hiding its already-synced files (it is a visual signal only — never an access gate). Always-on connectors (local disk, the vector index) omit it.category(optional) is a taxonomy string the connector declares for UI grouping — for exampledisk,vector,machine,api,db. It surfaces on the source-kinds payload and drives the icon and colour of the connector's chip in the config panel. The platform never derives it, so a new connector kind simply declares its own category; an unrecognised value renders a generic chip.
Trust gates the whole contribution: first-party and trusted packages may contribute source connectors with any capability; an untrusted package is rejected at manifest validation and again at runtime registration.
Offering an in-process MCP connector
A connectors[] entry can also carry type: "mcp" instead of type: "source".
Most MCP connectors are outbound — they point at an external server over
transport: "streamable-http" (with a url) or transport: "stdio" (with a
command). A third form, transport: "in-process", is inbound: instead of
reaching out, the package advertises its own hosted tools as a single
discoverable connector, served through the package's hosted-tools path by the
platform's MCP host. It therefore needs neither a url nor a command —
the tools already live in-process.
{
"id": "git",
"type": "mcp",
"transport": "in-process",
"name": "Git",
"icon": "GitBranch",
"category": "mcp",
"requiredScopes": ["project"]
}Because an in-process descriptor is inbound, it is deliberately not treated
like an outbound server: it never enters the outbound connector store and is
never auto-connected — doing so would surface a phantom "connected" row for a
server that does not exist. It simply appears in the agent's <packages>
overview (rendered from the manifest) so the model knows the capability is
available; the tools themselves flow through the same hosted-tools boundary as
every other tool the package exposes.
Trust floor. In-process MCP is in-host execution, so it carries the same
minimum trust as stdio and sidecar transports: a trusted (or first-party)
package only. Untrusted and sandboxed packages are rejected at manifest
validation.
Two optional descriptor fields are advisory metadata — they annotate the
connector for tooling and UI but gate nothing on their own. The real gates
stay where they always are: the package's MCP-hosting enable list, each hosted
tool's requires.features, and the source URI policy.
defaultToolFilter— an{ include?, exclude? }hint for which of the package's hosted tools this connector nominally surfaces.requiredScopes— the connector's operating scopes. The closed set islocal,project,user,app, andmanaged, the same valuesPackageConnector.scopestakes.
Credentials are not described here either: a connector's secrets are
declared in neuralis.credentials[] and resolved through the injected
CredentialResolver, at whatever scope the operator stored them.
Declaring sources
Registering a connector adds a source kind; it does not create any source.
A sources[] entry in the manifest declares a concrete source instance of
an already-registered kind that the platform either seeds into every new project
or surfaces for on-demand add:
"sources": [
{
"source": "data",
"label": "Project Data",
"kind": "local",
"root": "data",
"scope": "project",
"description": "The project's shared working directory.",
"sync": { "trigger": "auto", "exclude": ["_pending/**"], "alwaysActive": true },
"permissions": { "default": { "read": true, "write": true, "exec": true } },
"seed": "auto"
},
{
"source": "notion",
"label": "Notion Workspace",
"kind": "notion",
"root": "data",
"scope": "project",
"description": "A connected Notion workspace, browsable as files.",
"seed": "discoverable"
}
]sourceis the canonical slug (project scope; user- and agent-scoped clones re-qualify it at attach time). Duplicate slugs within one manifest are rejected.kindmust be a registered connector kind. That a kind exists is checked at runtime when the source is created, not at manifest validation.rootis the connector root token: project-relative (data,_packages) or the host-resolved platform token${appRoot}. Omit it for rootless kinds such as the vector index or a container-backed desktop.scopepicks project-, user-, or agent-ownership for the seeded or added source.descriptionis required — it is rendered into the agent's runtime-stack system-prompt block so the model knows what the source is. It also prefills the discoverable-add form.seedisauto(created at project initialization) ordiscoverable(offered in the "add a source" surface, never created automatically).permissionsseeds the source's permission tree; the package's URI policy baselines still union on top.visibility.seeRequiresis an optional feature hint for who sees the discoverable card — a UX default only; the authoritative see/add gate stays server-side.
A package may declare a source on a kind it does not own — for example a
privileged package declaring the platform application zone on the built-in
local kind.
The declaring package's id becomes the source's origin attribution, surfaced on the source listing and tagged in the runtime-stack block. For rooted local sources the attribution requires both the slug and the resolved root to match, so a user-created mount that merely reuses a declared slug is not mis-attributed.
Trust gate
Source declarations are gated at manifest load, deny-by-default:
seed: "auto"requirestrustedorfirst-partytrust — auto sources are seeded into every project with no human in the loop.- A platform root token (
${appRoot}) requiresfirst-partytrust. - Untrusted and sandboxed packages may only declare
seed: "discoverable"with a project-relative root.
The root token allow-list is data, _packages, or ${appRoot}; absolute OS
paths, .. segments, and unknown ${…} tokens are rejected.
Implementing the port
Pin the version your implementation supports as a literal. Importing the host's runtime constant can falsely label an old binary after an upgrade. v1 execution signatures are rejected without an adapter; a version check alone cannot prove an arbitrary external binary enforces authorization.
Optional exec receives { command, cwd, timeout?, env?, background? } and a
separate { session: SessionContext, signal? }. cwd is the exact source URI.
The provider resolves its path and enforces its lifecycle and supported options;
identity never comes from tool input. Return exitCode, stdout, stderr,
truncated, durationMs, plus optional bounded display text.
A connector never implements the entry check. Declaring the exec capability
requires declaring execRequires beside it — a non-empty list of feature ids
that open a shell on this kind:
"capabilities": ["list", "read", "exec"],
"execRequires": ["exec.machine"]An empty list is a manifest error, because "every id in an empty list is
granted" is a door that is always open. The loader stamps a frozen copy of those
ids on the instance's binding record and wraps exec so each one is checked
before your code runs, and the consuming tool evaluates the source's own path
policy on the cwd after that. Your provider gets the call only once both have
passed.
The loader snapshots provider id/trust, kind, source, project root, scope and
the declared entry ids before awaiting the factory, then stamps only a
successfully checked instance. getConnectorBindingProvenance exposes this
immutable record; execution callers compare it with current provider, source and
project authority. Reusing one instance for a different binding is refused. Exec
factories require an absolute projectRoot; filesystem-only factories may omit
it. Filesystem methods keep
their existing service-layer URI-policy checks.
import {
type ConnectorPortVersion,
type ConnectorPort,
type ConnectorFactoryContext,
} from '@neuralis/package-system/contracts';
class NotionConnector implements ConnectorPort {
readonly kind = 'notion';
readonly source: string;
// A connector shipped separately from the platform pins the version it was
// built against — importing the host's constant would relabel old code as
// current on the next upgrade.
readonly portVersion = 4 satisfies ConnectorPortVersion;
constructor(private readonly ctx: ConnectorFactoryContext) {
this.source = ctx.source; // never assume a fixed scheme
}
capabilities() {
return new Set(['list', 'read', 'write', 'walk', 'scanContent'] as const);
}
async list(uri: string) { /* ... */ }
async read(uri: string) { /* ... */ }
async write(uri: string, content: string | Uint8Array) { /* ... */ }
}
export function createNotionConnector(ctx: ConnectorFactoryContext): ConnectorPort {
return new NotionConnector(ctx);
}The factory context carries exactly six fields: packageId, packageRoot, the
bound source slug, the validated config, the optional projectRoot, and the
persisted ownership scope. There is deliberately no credentials map on it
— secrets are resolved through the CredentialResolver injected into
init(ctx), as above. Source slugs are URI schemes, and the registry may create
scoped variants for user/agent sources — always read ctx.source instead of
hard-coding a scheme.
Registries
Two registries with deliberately separate lifecycles back the system:
ConnectorRegistryholds runtime instances — one entry per kind plus per-source bindings (resolve(kind),resolveBySource(source)). Unregistering a kind clears every source binding that uses it.SourceKindRegistryholds the metadata (label, schema, capabilities, defaults, factory) used for discovery, admin forms, and config validation. Keeping it separate makes orphan sources (metadata gone, config still on disk) distinguishable from runtime failures.
Verify an implementation against the contract with the compliance harness
from @neuralis/package-system/testing — it exercises every declared
capability and the declared-versus-actual set match. See
Testing.