@oxstudio.io/paperclip-connector
v0.0.2
Published
Alpha: PaperClip plugin that pushes agent work products into Ox Studio datasets as content briefs and reports pipeline results back to the originating ticket.
Maintainers
Readme
@oxstudio.io/paperclip-connector
PaperClip plugin ("OxStudio Connector") that pushes agent work products into
Ox Studio datasets as CONTENT_BRIEF records and reports pipeline results back
to the originating ticket.
Alpha. The
0.0.xline tracks the PaperClip plugin API, which is itself alpha ("expect breaking changes while this feature settles"). Interfaces may change between releases without a deprecation window.
Upgrading from 0.0.1. The new version asks for three additional capabilities -
agent.tools.register(thesubmit-findingtool),issues.createandissues.wakeup(the optional issue feedback channel) - so PaperClip will ask an operator to approve them. Behavior does not change until you choose it:feedback.modedefaults tocomment, exactly what 0.0.1 did. Note that the state layout moves forward one way only: after the first poll on 0.0.2, deliveries awaiting confirmation are recorded in a shape 0.0.1 cannot read, so a downgrade would orphan them (the briefs themselves are already in Ox Studio; only the feedback for them would be lost).
Two ways in
PASSIVE agent attaches brief.json, then closes the ticket (status -> done)
-> issue.updated / agent.run.finished
-> brief from the attachment, or a fallback from title and description
-> POST {baseUrl}/ingest/datasets/:id/records
-> on failure: retry queue (5 min to 6 h backoff, up to 48 h)
ACTIVE agent calls the submit-finding tool mid-run
-> brief validated, dataset from the parameter or the mapping
-> POST {baseUrl}/ingest/datasets/:id/records
-> record ids returned synchronously, in the same run
Ox Studio
dataset record
| dataset trigger
v
sequence runs the
content pipeline
|
BACK feedback-poll job, every 5 minutes v
GET /ingest/content-outputs?externalRef= ContentOutput
-> comment on the originating ticket, and/or
-> a todo issue assigned to an agent, which is then wokenThe passive path suits "one ticket, one piece of content". The active path
suits a research agent that finds several topics in a single run and wants
each confirmed before the run ends. Both land in the same dataset, are
deduplicated by the same externalRef, and report back through the same
feedback loop.
Requirements
- A self-hosted PaperClip instance (Node.js >= 24.11, pnpm >= 9.15; dashboard
on
http://localhost:3100by default). New instance:npx paperclipai onboardand back upsecrets/master.keyas the PaperClip docs instruct. - An Ox Studio account with at least one project and a dataset of type
Content brief (
CONTENT_BRIEF).
Installation
Path A - npm package (recommended)
In the PaperClip dashboard open Settings -> Plugins, click Install Plugin and enter the package name:
@oxstudio.io/paperclip-connectoror from the CLI:
npx paperclipai plugin install @oxstudio.io/paperclip-connectorPath B - git clone (development)
git clone <this repo> oxstudio-paperclip-connector
cd oxstudio-paperclip-connector
pnpm install
pnpm build # the host resolves ./dist/* from the manifest, so build first
npx paperclipai plugin install .After changing plugin code run pnpm build, then reload the worker explicitly:
npx paperclipai plugin upgrade oxstudio.connectorThat re-reads the manifest from disk, restarts the worker on the new bundle and
leaves the saved configuration alone. Do not rely on the dev watcher: despite
the message the installer prints, this host does not restart the worker when
dist/ changes (see Field notes).
Either way, "OxStudio Connector" appears under Settings -> Plugins with
status ready.
Configuration, step by step
Generate a service key in Ox Studio: open Settings -> Integrations -> Data Sources -> Add data input, pick the PaperClip AI preset, choose the project and the target dataset (or create one - schema type Content brief), optionally wire a sequence trigger, and click Generate. Copy the one-time service key (
oxs_…) - it is shown exactly once.
Find the dataset ID: open the dataset page in Ox Studio and read the number from the URL:
/projects/<projectId>/datasets/<datasetId>.Configure the plugin: in PaperClip open Settings -> Plugins -> OxStudio Connector and fill the form:
- Ox Studio base URL - the ingest API origin, e.g.
https://app.oxstudio.example/api/data(the wizard's instructions show the exact URL for your instance), - Service key - paste the
oxs_…key; on save it becomes an encrypted PaperClip company secret and the form only ever shows configured / not configured, - Dataset mappings - at least one rule; leave the matchers empty to route every finished ticket to that dataset ID,
- Result feedback - leave enabled to get pipeline results back as ticket comments.
Click Save configuration, then Test connection - it sends a small self-test brief (
externalRef paperclip:selftest:…) to the first mapped dataset; on the Ox Studio Data Sources page the input goes Idle -> Active.
- Ox Studio base URL - the ingest API origin, e.g.
Development
pnpm install
pnpm build # bundles dist/worker.js + dist/manifest.js + dist/ui (esbuild presets from the SDK)
pnpm test # vitest (config schema, mapping, ingest client, pipeline, feedback loop)
pnpm typecheck
pnpm lint
pnpm dev # esbuild watch modeTroubleshooting
| Symptom | Cause and fix |
| --- | --- |
| Test connection: "Service key rejected" / health degraded with lastAuthFailureAt | The key was revoked or the input disabled in Ox Studio (deleting a data input permanently revokes its key). Generate a new key in the Data Sources wizard and paste it into the settings form. |
| Test connection: "Dataset not found for this key" (404) | The dataset ID belongs to a different project than the service key. Keys are project-scoped - generate the key in the same project as the dataset. |
| Briefs deliver but no comment ever comes back | The dataset has no sequence trigger (Ox Studio dataset page -> Automation), or the feedback section is disabled. Self-hosted instances always receive feedback via polling - push callbacks cannot reach a PaperClip behind NAT/localhost (Ox Studio's anti-SSRF callback policy). |
| Ticket closed but nothing was sent | No mapping matched (check agent/project/label matchers), the ticket's status is not done, or the ticket title is shorter than 3 characters with no brief.json (the plugin comments the reason on the ticket in that case). |
| Plugin status error after a local-path install | The built worker is missing - run pnpm build in the plugin directory, then uninstall and install again so the server persists the package path. |
| Diagnostics shows "Ox Studio unreachable" | Check oxstudioBaseUrl, that the Ox Studio data manager is running, and that the address is reachable from the machine running the PaperClip worker (a private address is fine - the connector uses direct fetch, not ctx.http). |
| Diagnostics is green but no content is produced | The connector confirms delivery, not production. Check the Ox Studio side: the dataset trigger, the project processor and free sequence concurrency slots. |
| Feedback issue appears but the agent never starts | The wakeup was rejected. Check heartbeat-runs for the agent and its maxConcurrentRuns; the connector records the failure on the delivery (wakeup.ok = false) and does not retry it. |
| A finished run sent nothing | The run had no ticket behind it (payload.issueId was null). That is correct behavior - use the submit-finding tool for work that is not tied to a ticket. |
| Diagnostics shows deliveries waiting for retry | Ox Studio was unreachable or rejected the key when the ticket closed. Fix the cause, then press Retry deliveries now instead of waiting for the backoff. |
For agents: the submit-finding tool
An agent does not have to close a ticket to send work to Ox Studio. The plugin
registers a tool, oxstudio.connector:submit-finding, that an agent can call
mid-run:
| Parameter | |
| --- | --- |
| topic (required) | What the content should be about, one sentence |
| notes | Findings: facts, angles, quotes, numbers |
| angle, audience, keyMessages, language | Optional brief fields |
| sources | URLs backing the findings |
| datasetId | Explicit target; omit to use the configured mapping for the calling agent or project |
| externalRef | Stable id; resubmitting the same ref is a no-op. Omit and one is derived from the run and the topic |
The call is synchronous: on success the agent gets back
{ outcome: "created", datasetId, recordIds, externalRef } and can confirm the
submission before the run ends. Other outcomes are equally explicit -
skipped (already submitted), queued (Ox Studio unreachable, the brief is in
the retry queue, do not resubmit), or an error sentence saying whether the
agent should fix the brief or fetch an operator.
Operator step: allow the tool. PaperClip gates plugin tools behind its tool
access policy, which denies by default (tool-access-policy.js). Until
oxstudio.connector:submit-finding is allowed in the calling agent's tool
profile, the agent will not see the tool and the handler is never reached.
That is the first thing to check when an agent reports the tool missing.
To exercise it by hand:
curl -sS -X POST http://127.0.0.1:3100/api/plugins/tools/execute \
-H 'content-type: application/json' \
-H "authorization: Bearer $PAPERCLIP_TOKEN" \
-d '{"tool":"oxstudio.connector:submit-finding",
"parameters":{"topic":"...","notes":"..."},
"runContext":{"agentId":"...","runId":"...","companyId":"...","projectId":"..."}}'or through the CLI:
npx paperclipai plugin tool:execute --payload-json '{"tool":"oxstudio.connector:submit-finding", ...}'runContext is validated against the database, so all four ids must be real
and belong together: the agent and the project must belong to the company, and
the run must belong to that agent. Fabricated ids are rejected with HTTP 403
before the plugin is reached.
Diagnostics
The settings page opens with a Diagnostics card: a single status badge (healthy / degraded / not working), one line per check (configuration, service key, dataset mappings, Ox Studio reachability, deliveries waiting for retry), the counters and timestamps that matter (last accepted delivery, last feedback poll, retry queue depth and next attempt, pending confirmations), and three buttons - Run diagnostics, Retry deliveries now and Poll now.
Two things worth knowing:
- Run diagnostics only reads. It calls
GET {baseUrl}/ingest/content-outputs?externalRef=paperclip:diagnostics:probe(scopeoutput:read) with the configured service key, so it exercises the same host, network path and credential as a delivery without creating anything. Test connection, by contrast, writes a real self-test brief into the first mapped dataset. - This card is the only place the connector reports on itself. PaperClip's
own plugin health (the
readybadge, the Status tab,GET /api/plugins/:id/health) reports host checks only - registry, manifest and status - so it stays green even when every delivery is failing.
The card never carries a secret value: the service key appears exclusively as a boolean ("Service key resolvable"), and a test asserts that no resolved secret can reach the UI bridge.
Settings page and feedback loop
The plugin contributes a settingsPage UI slot, so Settings -> Plugins ->
OxStudio Connector shows a dedicated form: Ox Studio base URL, the service
key (pasted once - it becomes an encrypted PaperClip company secret and the
form afterwards only shows configured / not configured), the mapping editor
(agents / projects / labels -> dataset ID) and the feedback section. The form
validates with the same Zod schema the worker uses. Test connection sends
a small self-test brief (externalRef paperclip:selftest:<ts>) to the first
mapped dataset and reports success / bad key (401) / dataset outside the key's
project (404) / network.
The feedback loop is a scheduled job (feedback-poll, cron every 5 minutes,
paced per company by feedback.pollMinutes): for every delivery awaiting
confirmation it calls GET {baseUrl}/ingest/content-outputs?externalRef=…
(scope output:read). A terminal output (SUCCESS/FAILURE) is reported exactly
once through the configured channels (see Feedback modes), carrying status,
execution, title and the permalink when the output payload has one. The
confirmedAt mark lives in plugin state, so worker restarts never duplicate a
comment or an issue. Deliveries with no output for 7 days are marked expired
and logged, silently. The same job first drains the retry queue, and it does
that even when the feedback loop is disabled: undelivered briefs are not
feedback, and turning feedback off must not discard them.
Polling rather than a push callback, because a self-hosted PaperClip behind NAT or localhost is unreachable for Ox Studio's anti-SSRF callback policy.
Feedback modes
feedback.mode decides where a finished content result goes:
| Mode | What happens |
| --- | --- |
| comment (default) | A comment on the originating ticket. Unchanged from 0.0.1. |
| issue | A todo issue carrying the result, assigned to a chosen agent, which is then woken. |
| both | Comment and issue. |
The issue modes exist because a comment records a result but does not act on it. The agent that ordered the content finished its run long ago; nothing in PaperClip picks the outcome up. Pointing the result at an agent - a librarian that files it, links it to the topic, or decides what comes next - closes that loop. The connector deliberately stops there: it delivers the facts and wakes the right agent, and every decision about what the result means belongs to that agent, which has the context the plugin does not.
Two details worth knowing:
- Submissions made through
submit-findinghave no ticket, so incommentmode they have nowhere to report back to. The connector confirms them and logs why once. If agents use the tool, pickissueorboth. - Creating the issue does not start the agent.
ctx.issues.createonly assigns it; the connector follows with an explicitctx.issues.requestWakeup(idempotency key derived from the brief reference). If the wakeup fails - an agent at its concurrency limit, say - the issue still exists and the result is still confirmed, so nothing is delivered twice.
Configuration
| Field | Description |
| --- | --- |
| oxstudioBaseUrl | Ox Studio ingest API origin, e.g. https://app.oxstudio.example/api/data |
| serviceKeySecretRef | Service key from the Ox Studio Data Sources wizard. Declared with format: "secret-ref", so the host stores it as an encrypted secret and the worker only ever sees a reference; the value is resolved at call time via ctx.secrets.resolve() and is never logged or persisted by the plugin. |
| mappings[] | Routing rules (agent / project / label -> datasetId); first match wins, empty matcher lists match everything |
| feedback | { enabled, pollMinutes, mode, issueAssigneeAgentId?, issueProjectId? } for the result feedback loop. See Feedback modes above. The job runs on a 5-minute cron, so 5 minutes is the finest cadence that exists: smaller values are raised to 5 on save (with a warning) rather than rejected, and Poll now on the settings page covers the "check right now" case. |
The manifest carries the JSON Schema (host renders the settings form from it);
src/config.ts holds the equivalent Zod schema the worker validates against at
runtime (onValidateConfig, onConfigChanged, health checks).
How work products become briefs
The worker listens to issue.updated (primary trigger: ticket status becomes
done) and agent.run.finished (for runs that carry an issueId). For each
candidate ticket it resolves the target dataset from mappings, reads the
work product and delivers one CONTENT_BRIEF record to
POST {oxstudioBaseUrl}/ingest/datasets/:datasetId/records.
externalRef is always paperclip:<companyId>:<issueId> for tickets and
paperclip:<companyId>:run:<runId>:<hash of the topic> for tool submissions -
stable per unit of work, so repeating an action cannot create duplicates (the
send registry in plugin state skips locally, and the ingest API dedupes
server-side by returning skipped).
Delivery failures are handled by class:
| Failure | What happens |
| --- | --- |
| Network error or 5xx | The brief goes into the retry queue with the record marked pending. Backoff runs 5 min, 15 min, 45 min, 2 h 15, then 6 h, giving up after 10 attempts or 48 hours. |
| 401/403 (rejected key) | Also queued, but the retry pass stops without burning an attempt: nothing succeeds until an operator fixes the key. Diagnostics shows the rejection. |
| 400/422 (bad brief) | Not retried - the payload will not improve. The reason is commented on the ticket and the delivery is marked failed. |
Binary attachments (images, video) are NOT transferred - the dataset accepts
JSON only; links can travel in sources[].
Agent conventions
Attach brief.json first, set the ticket to done second. The connector
reads the attachments at the moment the ticket closes and never re-reads them.
A ticket closed without the attachment gets a best-effort brief assembled from
the title, the description and the last agent comment, and attaching the file
afterwards changes nothing: the externalRef is already spent, so Ox Studio
answers skipped. There is no second chance, by design - that is also what
makes re-closing a ticket safe.
If the agent can call tools, submit-finding is the better path: it validates
immediately, returns the record ids inside the same run, and has no ordering
requirement at all.
The brief.json payload follows the Ox Studio content-brief contract
(mirrored in src/mapping/content-brief.ts): topic required, 3-300
characters; optional angle, audience, keyMessages[], mustInclude[],
mustAvoid[], sources[] with {url|excerpt, title, authority 0-1},
targets, language, notes. The attachment is authoritative - an invalid
brief.json is rejected with a comment on the ticket, not silently replaced.
Snippet to paste into an agent's instructions:
When you finish research that should become content:
1. Preferred: call the `submit-finding` tool with `topic` and `notes`. It returns the Ox Studio
record ids, so you can confirm the submission before ending the run.
2. Fallback (no tool access): attach a `brief.json` file to the ticket, then set the ticket to `done`.
Attach the file BEFORE changing the status - the connector reads attachments at the moment the
ticket closes and never re-reads them.Ox Studio side: what must be running
The connector confirms delivery, not production. A green Diagnostics card means the brief reached Ox Studio; whether content comes out the other end depends on the Ox Studio instance:
| Requirement | Symptom when it is missing |
| --- | --- |
| DatasetTriggerSequence enabled in SystemSettings.SERVICES_ACTIVITY | The record sits in the dataset and no execution is ever created. |
| A running project-processor | The execution is created and stays PENDING. |
| Free maxConcurrency slots on the sequence | Executions queue indefinitely. The usual cause is hung executions holding slots; close them in the Ox Studio UI to release them. |
| A service key with both dataset:write and output:read | Deliveries work, but Diagnostics and the feedback loop return 401. Keys from the Data Sources wizard carry both. |
Field notes (SDK findings worth keeping)
Knowledge gathered from the PaperClip sources that the SDK docs do not spell out - kept here for future connectors:
- A plugin is an npm package with a
paperclipPluginblock inpackage.jsonpointing at built bundles:{ "manifest": "./dist/manifest.js", "worker": "./dist/worker.js", "ui": "./dist/ui/" }.dist/manifest.jsmust exist at install time. instanceConfigSchemain the manifest is JSON Schema, not Zod (the manifest is serialized into the DB). Zod is for runtime validation inside the worker; the SDK re-exports it (import { z } from '@paperclipai/plugin-sdk', zod v4).- Declaring a config field with
format: "secret-ref"makes the dashboard render a secret picker; the stored value is anEnvSecretRefBinding({ type: "secret_ref", secretId, ... }) or a raw string that the server converts to a stored secret on save.ctx.secrets.resolve()accepts both. onHealth()takes no context argument - keep thePluginContextfromsetup()in module scope.ctx.config.get(companyId?)is company-scoped; remember thecompanyIdfromonConfigChanged(newConfig, { companyId }).runWorker(plugin, import.meta.url)at the bottom of the worker is safe to import from tests - it verifies the module is the process entrypoint and returns otherwise.@paperclipai/plugin-sdk/testingexportscreateTestHarness({ manifest, config? })with an in-memory host;@paperclipai/plugin-sdk/bundlersexportscreatePluginBundlerPresets()with ready esbuild configs for worker, manifest and UI bundles.- The host validates the manifest on install and rejects an empty
ui.slotsarray (ui.slots: Too small: expected array to have >=1 items) - omituientirely until the plugin ships a real slot. - The server validates saved config strictly against
instanceConfigSchema(ajv). Aformat: "secret-ref"field must NOT declaretype: "string"- the picker stores a binding object{ type: "secret_ref", secretId }and a string type rejects it ("Configuration does not match the plugin's instanceConfigSchema"). Declare the field withformatonly. - The "paste a raw value" fallback in the secret field stores the raw string
in
configJson(no automatic conversion to a stored secret), andctx.secrets.resolvefails closed on non-UUID strings. Always create a company secret (Settings -> Secrets or the field's "Create secret" button) and bind it. ctx.http.fetchresolves DNS and only allows public addresses (it filters private/reserved ranges). For a connector whose target service lives on the operator's own network, use standard Nodefetch- the SDK documents that direct fetch is allowed andctx.httpexists for host-managed tracing.- A
settingsPageUI slot REPLACES the auto-generated config form on the plugin page ("If the plugin has a custom settingsPage slot, prefer that over auto-generated form" - ui/src/pages/PluginSettings.tsx), so a custom settings page must implement config editing itself. Plugin UI runs same-origin with the dashboard session and may call host APIs directly (/api/plugins/<pluginKey>/config,/api/companies/:id/secrets). - In UI bundles never import the SDK root (
@paperclipai/plugin-sdk) - it drags in Node-only modules (dev-server) and the browser build fails onnode:fs. Import@paperclipai/plugin-sdk/uiand depend onzoddirectly. GET /api/plugins/:id/healthand the dashboard's "Health Status" card return the host's own checks only (registry,manifest,status); the host in 2026.831.1 never calls the worker'sonHealth(). A plugin that wants to tell an operator something about itself has to render it, which is why the connector's diagnostics live on its settings page.onHealthstays implemented and delegates to the same collector, ready for a host version that starts calling it.ctx.issues.createassigns an agent but does not start it. A created issue sits in the assignee's queue until something requests a wakeup, so a plugin that wants an agent to act has to callctx.issues.requestWakeup(issueId, companyId, { reason, idempotencyKey })itself.originKindfor plugins is the template literal typeplugin:${string}, which makes plugin-created issues filterable viactx.issues.list({ originKindPrefix }).- Run-lifecycle events ARE emitted, but not through the activity log: the
server calls
publishRunLifecyclePluginEvent(run)in@paperclipai/server/dist/services/heartbeat.jsand mapsrun.statustoagent.run.started/agent.run.finished(succeeded) /agent.run.failed(failed or timed_out) /agent.run.cancelled. There is noagent.run.completed. The envelope carriesentityType: "heartbeat_run"andentityId = run.id, so the ticket must be read frompayload.issueId(sourced fromrun.contextSnapshot,nullfor runs without a ticket). Ticket status changes are a separate path:issue.updatedfrom the activity log (server/src/services/activity-log.ts), emitted post-commit, withentityId= issue id. - When auditing host behavior, read the installed package
(
~/.npm/_npx/*/node_modules/@paperclipai/server/dist). GitHub code search does not return every server file, and an earlier audit of this connector concluded from it thatagent.run.finishedwas never emitted - it is. - Local-path installs:
npx paperclipai plugin install <path>; the dashboard can also install by npm package name (Install Plugin button). Reinstalling over an existing install returnsPlugin already installed. - For a manifest change on a local-path install,
npx paperclipai plugin upgrade <key>is the cheap path: it re-reads the manifest from disk, keepsconfigJsonuntouched and restarts the worker.uninstall+install .also works and (without--force) likewise preserves the config and even the same plugin row;--forceis a hard purge of config and state, so use it only when you mean to wipe. Caveat fromplugin-lifecycle.js: if the new manifest ADDS capabilities,upgradeparks the plugin inupgrade_pendingwith the worker stopped until an operator approves. - The dev watcher does not reload the worker here.
onboard.logshowsplugin-dev-watcher: initializedbut neverwatching local plugin for changes, and apnpm buildproduces no newworker startedline even though the plugin was installed from a local path. After rebuilding, reload explicitly withplugin disable <key> && plugin enable <key>(orplugin upgrade <key>), otherwise every live check runs against the old bundle. - Do not put a real floor in
instanceConfigSchema. The host validatesconfigJsonagainst that schema and returns HTTP 400 before calling the worker'svalidateConfig, so a tightenedminimumboth makesonValidateConfigwarnings unreachable and locks operators out of re-saving a config that already stores a smaller value. Keep the manifest schema permissive, enforce the limit in the Zod schema (clamp on read) and in the settings page input, and surface the adjustment as awarnings[]entry - the host renders it asWarnings: …fromconfig/test.
License
MIT
