Imported from devena/glyvio-forge (
claude/skills/query-external-datasource/SKILL.md). Install upstream withnpx skills add devena/glyvio-forge --skill query-external-datasource. Copyright stays with the author.
Agent Skill: Query External Datasource via Sync
This document defines a structured AI agent skill. Other AI coding agents or developers can load and execute this skill to write code that reaches a third-party database/API on demand, exclusively through glyvio-plugin-sync's SyncClient.
⚠️
sync.SyncClientis the only sanctioned path to a third-party system. Never open a raw DB driver, HTTP client, or direct connection to an external ERP/marketplace/system from@Action,@CustomTool,@SystemTool, interceptor, or controller code, in any layer. The user registers the third-party connection once (as adataSource) via the Sync admin UI; from then on, all plugin code reaches it only throughSyncClient.Not a substitute for
create-sync-interceptor. That skill is reactive — it fires automatically when the scheduled sync pipeline pulls a record in. This skill is for code that actively asks the sync engine to read or trigger something right now (e.g. a button, an@Action, a Jeannie@CustomTool).Not a config-authoring skill.
dataSources,tasks, andscheduledTasksare configured by the end user through the Sync plugin's own admin page (sync_config_edit_page.ts). This skill never creates or edits that config — it only discovers existing names (viagetConfig()) and writes code against them.
🎯 Skill Metadata
{
"name": "query_external_datasource",
"description": "Writes code that calls sync.SyncClient to read or trigger a third-party datasource on demand (ad-hoc query, force-run a task, or ignore a record). Does not author sync config and does not handle inbound sync data.",
"Audience": "AI agents or developers with write access to a Glyvio plugin's plugin/server, plugin/app, or plugin/environment codebase.",
"parameters": {
"type": "object",
"properties": {
"accessPattern": {
"type": "string",
"enum": ["adHocQuery", "aiContextQuery", "forceSyncTask", "ignoreRecord", "readConfig"],
"description": "Which SyncClient capability the task needs."
},
"dataSourceName": {
"type": "string",
"description": "Name of the already-registered dataSource to query against (required for adHocQuery/aiContextQuery)."
},
"taskName": {
"type": "string",
"description": "Name of the already-configured sync task to force-run (required for forceSyncTask)."
}
},
"required": ["accessPattern"]
}
}
📥 Required Input Parameters
- Access pattern — which of the five
SyncClientcapabilities the task needs (see table below). dataSourceName/taskName— the exact, already-registered name to target. Never invent these. If unknown, either ask the user or callsync.SyncClient().getConfig()first and readdataSources/tasksfrom the result.- Call context (
environmentId, optionalsecretId) — everySyncClientmethod returns a thunk requiring.call({ environmentId, secretId }); source these from the current request/session context, the same way other cross-plugin service calls in this codebase do. - Target layer —
plugin/server,plugin/app, orplugin/environment.SyncClientexposes the identical 9 methods in all three (it's a separately generatedservice.tsper layer), but.call(...)is synchronous inplugin/serverand returns aPromiseinplugin/app/plugin/environment(mustawait) — see constraint 2. - Business logic — what to do with the result (e.g. map external rows onto a local entity, decide when to force a resync, which record to exclude and why).
🚫 Environment Constraints & Rules
- Global namespace, no import: the consuming plugin must declare a dependency on the sync plugin in its own
manifest.json:
Once declared,{ "dependencies": [{ "pluginName": "sync", "version": "latest" }] }sync.SyncClient(and its types) are available as an ambient global inplugin/server/src/**,plugin/app/src/**, andplugin/environment/src/**alike — same convention asglyvio_core.*/glyvio_entity.*. Do notimportit. - Available in all three layers, but sync vs. async:
SyncClientexposes the same 9 methods inplugin/server,plugin/app, andplugin/environment. Inplugin/server,.call(...)returns the result directly (synchronous). Inplugin/appandplugin/environment,.call(...)returns aPromise— you mustawaitit. Do not addawaiton the server side (that would be a type error, mirroring the sync/async split documented forQueryBuilderincreate-sync-interceptor) and do not forget it on the app/environment side. - Raw SQL, external schema:
queryList/queryFirst/jeannieQueryList/jeannieQueryFirstrun the givenquerystring against the third-party system's own schema (via the registereddataSource), not againstglyvio_entitymodels. Column names are whatever the external system uses — do not assume they match local entity fields. - Config is read-only from here: use
getConfig()to discover validdataSourceName/taskName/entityNamevalues. Never generate or writeSyncConfigModel/SyncTaskConfigModelentries — that is the end user's job in the Sync admin UI. - No default try-catch: let
SyncClientcall failures propagate. Throwglyvio_core.GlyvioErrorfor business-relevant failures (e.g. "no rows returned from ERP for this code"). - Type Safety: type the generic on
queryList<T>/queryFirst<T>/jeannieQueryList<T>/jeannieQueryFirst<T>to the shape of the external row you expect — this is a plain data shape you define, not aglyvio_entityclass.
📋 Access Patterns Reference
| Need | Method | Key args | Returns |
|---|---|---|---|
| Ad-hoc read against an external DB | queryList<T> / queryFirst<T> |
{ query, dataSourceName } |
{ result: T[] } / { result: T | undefined } |
| Read scoped to an AI agent / Jeannie conversation | jeannieQueryList<T> / jeannieQueryFirst<T> |
{ query, appUserId, zoneInfo? } |
same shape as above |
| Force a configured task to run now (instead of waiting for its cron) | callTask |
{ taskName, predicate: { predicateGlyvio?, predicateDataSource? }, forced, extraAttributes? } |
{ value, requestId } |
| Exclude one record from all future sync runs | putIgnoreId / putIgnoreIc |
{ id, entityName } / { ic, entityName } |
{ value } |
| Discover registered dataSources/tasks/scheduledTasks | getConfig |
— | { value?: SyncConfigModel } |
| Check whether the environment's initial full sync has completed | isFirstLoadDone |
— | { value } |
Every method returns { call: (args: { environmentId, secretId? }) => Result }. In plugin/server, Result is the plain value; in plugin/app/plugin/environment, Result is Promise<...> — you must invoke (and, outside plugin/server, await) .call(...) to actually execute it.
📋 Execution Steps
- Resolve the target name. If
dataSourceName/taskNamewasn't given, callsync.SyncClient().getConfig().call({ environmentId })and read.value.dataSources/.value.tasksto confirm the exact name — or ask the user. - Declare the dependency. Check the plugin's
manifest.jsonfor{ "pluginName": "sync" }underdependencies; add it if missing (this makes thesyncglobal namespace available at compile time). - Write the call site in the layer where the trigger originates — an
@Action/@CustomTool/controller inplugin/server, a@SystemTool/@Actioninplugin/environment, or a UI handler inplugin/app— never inside a@SyncInterceptor(that's the inbound/reactive path, seecreate-sync-interceptor). - Build check: compile the affected subproject(s) (
pnpm run build:fastorpnpm tsc --noEmit) to confirm types resolve, paying attention to the sync/async split from constraint 2.
📄 Code Blueprint (Template)
plugin/server — synchronous .call(...):
interface ExternalOrderRow {
order_code: string;
customer_document: string;
total_amount: number;
}
const result = new sync.SyncClient()
.queryList<ExternalOrderRow>({
query: `SELECT order_code, customer_document, total_amount FROM orders WHERE updated_at > '<since>'`,
dataSourceName: '<RegisteredDataSourceName>',
})
.call({ environmentId: context.environmentId });
if (!result?.result.length) {
throw new glyvio_core.GlyvioError({ message: 'No rows returned from external datasource' });
}
// Force a specific task to run now instead of waiting for its schedule:
new sync.SyncClient()
.callTask({
taskName: '<RegisteredTaskName>',
predicate: { predicateGlyvio: undefined, predicateDataSource: undefined },
forced: true,
})
.call({ environmentId: context.environmentId });
plugin/app / plugin/environment — same API, but .call(...) returns a Promise:
const result = await new sync.SyncClient()
.queryList<ExternalOrderRow>({
query: `SELECT order_code, customer_document, total_amount FROM orders WHERE updated_at > '<since>'`,
dataSourceName: '<RegisteredDataSourceName>',
})
.call({ environmentId: context.environmentId });
✅ Completion Checklist
-
dataSourceName/taskNameconfirmed againstgetConfig()or the user — never invented. -
syncdeclared as a dependency inmanifest.json(pluginName: "sync"). - Call site lives in the correct layer (
@Action/@CustomTool/controller in server,@SystemTool/@Actionin environment, UI handler in app), not in an interceptor. -
awaitused on.call(...)inplugin/app/plugin/environment, omitted inplugin/server. - No config authored or edited (
dataSources/tasks/scheduledTasksare the end user's responsibility). - No try-catch unless explicitly requested; business failures throw
glyvio_core.GlyvioError. - Generic row types (
<T>) reflect the external system's own schema, not aglyvio_entitymodel. - Build passes (
pnpm run build:fast) for every layer touched.