Skip to main content

After a first Skills persist, teams still need to prove a later publish becomes a version rather than a duplicate, retrieve that exact version, and inspect the stored zip without executing it. This recipe is that versioning job. The Beginner validate-and-publish-skill quickstart owns the first persist.

The runnable scaffold opts into the experimental Skills API through the official SDK, keeps validation separate from persistence, verifies version supersession directly, and treats downloaded archives as untrusted.

Configure the Skills API client

Resolve the reader's tenant, use the native Skills scopes or the recorded legacy compatibility mode, and enable experimental endpoints through the SDK constructor.

src/client.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import { Glean, type SDKOptions } from '@gleanwork/api-client';
import type { XGleanOptions } from '@gleanwork/api-client/hooks/x-glean-options.js';
import { createGleanTokenProvider, discoverGleanTenant } from '@gleanwork/auth';

const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
const SCOPE_MODE_FILE = '.glean-scope-mode';

export interface GleanClientTarget {
email?: string;
serverUrl?: string;
}

async function loadDotEnv() {
let text: string;
try {
text = await fs.readFile(path.join(process.cwd(), '.env'), 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}

async function resolveServerUrl({ email, serverUrl }: GleanClientTarget) {
const explicit = serverUrl?.trim();
if (explicit) return explicit;

const workEmail = email?.trim();
if (workEmail) return (await discoverGleanTenant(workEmail)).serverUrl;

const configured = process.env.GLEAN_SERVER_URL?.trim();
if (configured) return configured;

throw new Error(
'Pass --email or --server-url, or set GLEAN_SERVER_URL in your environment.',
);
}

async function configuredScopes(log: (message: string) => void) {
const envMode = process.env.GLEAN_SKILLS_SCOPE_MODE?.trim();
let fileMode: string | undefined;
try {
fileMode = (
await fs.readFile(path.join(process.cwd(), SCOPE_MODE_FILE), 'utf8')
).trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (envMode && fileMode && envMode !== fileMode) {
log(
`GLEAN_SKILLS_SCOPE_MODE=${envMode} overrides .glean-scope-mode (${fileMode}).`,
);
} else if (envMode) {
log(`Using GLEAN_SKILLS_SCOPE_MODE=${envMode}.`);
} else if (fileMode) {
log(`Using .glean-scope-mode (${fileMode}).`);
} else {
log('Using native skills:read and skills:write scopes.');
}
const mode = envMode || fileMode;
return mode === 'legacy' ? ['SKILLS'] : ['skills:read', 'skills:write'];
}

export async function createGleanClient(
target: GleanClientTarget,
log: (message: string) => void = () => undefined,
) {
await loadDotEnv();
const serverURL = await resolveServerUrl(target);
const server = new URL(serverURL);
const loopback = LOOPBACK_HOSTS.has(server.hostname);
if (
(server.protocol !== 'https:' && !loopback) ||
server.username ||
server.password ||
server.search ||
server.hash ||
(server.pathname && server.pathname !== '/') ||
(!loopback && server.port)
) {
throw new Error('Use a complete Glean backend HTTPS origin.');
}

const staticToken = process.env.GLEAN_API_TOKEN?.trim();
let apiToken: string | ReturnType<typeof createGleanTokenProvider>;
if (staticToken) {
log('Using GLEAN_API_TOKEN from the environment.');
apiToken = staticToken;
} else {
const scopes = await configuredScopes(log);
log(`Using the OAuth session (${scopes.join(', ')}).`);
apiToken = createGleanTokenProvider({
serverUrl: server.origin,
scopes,
});
}

const options = {
serverURL: server.origin,
apiToken,
includeExperimental: true,
timeoutMs: 30_000,
retryConfig: {
strategy: 'backoff',
backoff: {
initialInterval: 500,
maxInterval: 5_000,
exponent: 2,
maxElapsedTime: 90_000,
},
retryConnectionErrors: true,
},
} satisfies SDKOptions & XGleanOptions;

return new Glean(options);
}

Prove supersession and inspect a version

Publish the same unique name twice, confirm the skill ID is stable while the version advances, then retrieve that version's metadata and content directly.

src/workflow.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Glean } from '@gleanwork/api-client';
import { CleanupFailedError } from './errors.js';
import { readBundle, readStream, stageDownloadedBundle } from './bundle.js';

export type SkillsApi = Pick<
Glean['skills'],
| 'create'
| 'delete'
| 'list'
| 'listVersions'
| 'retrieve'
| 'retrieveContent'
| 'retrieveVersion'
| 'retrieveVersionContent'
| 'validate'
>;

export interface PublishResult {
id: string;
displayName: string;
version: number;
minorVersion: number;
}

function rethrow(error: unknown): never {
throw error instanceof Error ? error : new Error('Verification failed.');
}

export function cleanupCommand(
skillId: string,
auth: { email?: string; serverUrl?: string } = {},
) {
const parts = [`npm start -- cleanup --id ${skillId} --yes`];
if (auth.serverUrl?.trim()) {
parts.push(`--server-url ${auth.serverUrl.trim()}`);
} else if (auth.email?.trim()) {
parts.push(`--email ${auth.email.trim()}`);
} else {
parts.push('--email <your-work-email>');
}
return parts.join(' ');
}

export function publishedSkillLine(result: PublishResult) {
return `Published ${result.displayName} (${result.id}) at version ${result.version}.${result.minorVersion}.`;
}

export function verifiedSuccessLine(result: PublishResult) {
return `Verified ${result.displayName} (${result.id}) at version ${result.version}.${result.minorVersion}; cleanup completed.`;
}

export function stagingDestination(
stageDir: string,
id: string,
version: number,
minorVersion: number,
) {
return path.resolve(stageDir, id, `v${version}.${minorVersion}`);
}

export async function findSkillByName(
api: SkillsApi,
displayName: string,
): Promise<string | undefined> {
let cursor: string | undefined;
do {
const page = await api.list(100, cursor);
const match = page.skills.find(
(skill) => skill.display_name === displayName,
);
if (match) return match.id;
cursor = page.next_cursor ?? undefined;
} while (cursor);
return undefined;
}

export async function deleteCapturedIds(
api: SkillsApi,
ids: string[],
log: (message: string) => void,
) {
const remaining: string[] = [];
for (const id of ids) {
log(`Deleting run-owned skill ${id}...`);
try {
await api.delete(id);
} catch {
remaining.push(id);
}
}
return remaining;
}

export async function publishBundle(
api: SkillsApi,
bundlePath: string,
): Promise<PublishResult> {
const bundle = await readBundle(bundlePath);
const validation = await api.validate({ file: bundle });
const created = await api.create({ file: bundle });
if (created.skill.display_name !== validation.metadata.display_name) {
throw new Error('Published skill name does not match validated metadata.');
}
return {
id: created.skill.id,
displayName: created.skill.display_name,
version: created.skill.latest_version,
minorVersion: created.skill.latest_minor_version,
};
}

export async function publishAndStage(
api: SkillsApi,
options: {
bundlePath: string;
stageDir: string;
log: (message: string) => void;
},
) {
const result = await publishBundle(api, options.bundlePath);
options.log(publishedSkillLine(result));
const destination = stagingDestination(
options.stageDir,
result.id,
result.version,
result.minorVersion,
);
const response = await api.retrieveContent(result.id);
const files = await stageDownloadedBundle(
await readStream(response.result),
destination,
);
return { result, files, destination };
}

function manifest(displayName: string, description: string) {
return `---\nname: ${displayName}\ndescription: ${description}\n---\n\n# Publishing verification\n\nThis fixture verifies the Skills publishing lifecycle. It contains no executable code.\n`;
}

function versionAdvanced(
first: { latest_version: number; latest_minor_version: number },
second: { latest_version: number; latest_minor_version: number },
) {
return (
second.latest_version > first.latest_version ||
(second.latest_version === first.latest_version &&
second.latest_minor_version > first.latest_minor_version)
);
}

async function assertManifest(
api: SkillsApi,
skillId: string,
expectedName: string,
destination: string,
version?: number,
) {
const response =
version === undefined
? await api.retrieveContent(skillId)
: await api.retrieveVersionContent(skillId, version);
const archive = await readStream(response.result);
const files = await stageDownloadedBundle(archive, destination);
const manifestPath = files.find(
(file) => path.posix.basename(file) === 'SKILL.md',
);
if (!manifestPath) throw new Error('Staged bundle has no SKILL.md.');
const content = await fs.readFile(
path.join(destination, manifestPath),
'utf8',
);
if (!content.includes(`name: ${expectedName}`)) {
throw new Error('Downloaded SKILL.md does not match the published skill.');
}
}

export async function verifyPublishingLifecycle(
api: SkillsApi,
options: {
workDir: string;
cleanup: boolean;
auth?: { email?: string; serverUrl?: string };
log?: (message: string) => void;
},
): Promise<PublishResult> {
const log = options.log ?? (() => undefined);
const uniqueName = `cookbook-publish-${randomBytes(8).toString('hex')}`;
const runRoot = path.join(options.workDir, uniqueName);
const firstPath = path.join(runRoot, 'v1', 'SKILL.md');
const secondPath = path.join(runRoot, 'v2', 'SKILL.md');
let createdId: string | undefined;
let result: PublishResult | undefined;
let workError: unknown;

await fs.mkdir(path.dirname(firstPath), { recursive: true });
await fs.mkdir(path.dirname(secondPath), { recursive: true });
await fs.writeFile(
firstPath,
manifest(uniqueName, 'Cookbook publishing verification version one.'),
{ flag: 'wx', mode: 0o600 },
);
await fs.writeFile(
secondPath,
manifest(uniqueName, 'Cookbook publishing verification version two.'),
{ flag: 'wx', mode: 0o600 },
);

try {
log('Validating the first bundle without persisting it...');
const firstBundle = await readBundle(firstPath);
const validation = await api.validate({ file: firstBundle });
if (validation.metadata.display_name !== uniqueName) {
throw new Error('Validation returned an unexpected skill name.');
}

log('Confirming invalid frontmatter is rejected without a create call...');
const invalidPath = path.join(runRoot, 'invalid', 'SKILL.md');
await fs.mkdir(path.dirname(invalidPath), { recursive: true });
await fs.writeFile(invalidPath, '# Missing frontmatter\n', {
flag: 'wx',
mode: 0o600,
});
let rejected = false;
try {
await api.validate({ file: await readBundle(invalidPath) });
} catch {
rejected = true;
}
if (!rejected) throw new Error('Invalid SKILL.md unexpectedly validated.');

log('Publishing the first version of a uniquely named skill...');
const first = await api.create({ file: firstBundle });
createdId = first.skill.id;
const retrieved = await api.retrieve(createdId);
if (retrieved.skill.id !== createdId) {
throw new Error('Direct retrieval returned a different skill.');
}
await assertManifest(
api,
createdId,
uniqueName,
path.join(runRoot, 'staged-latest-v1'),
);

log('Publishing the same name again to create a newer version...');
const second = await api.create({ file: await readBundle(secondPath) });
if (second.skill.id !== createdId) {
throw new Error('Name-based supersession created a different skill ID.');
}
if (!versionAdvanced(first.skill, second.skill)) {
throw new Error('The second publish did not advance the skill version.');
}

const versions = await api.listVersions(createdId, 100);
const latest = versions.versions.find(
(version) =>
version.version === second.skill.latest_version && version.is_latest,
);
if (!latest)
throw new Error('The new version is missing from listVersions.');
const version = await api.retrieveVersion(
createdId,
second.skill.latest_version,
);
if (version.version.skill_id !== createdId || !version.version.is_latest) {
throw new Error(
'Direct version retrieval did not return the latest version.',
);
}
await assertManifest(
api,
createdId,
uniqueName,
path.join(runRoot, 'staged-version-v2'),
second.skill.latest_version,
);

result = {
id: createdId,
displayName: second.skill.display_name,
version: second.skill.latest_version,
minorVersion: second.skill.latest_minor_version,
};
} catch (error) {
workError = error;
}

const remaining =
createdId && options.cleanup
? await deleteCapturedIds(api, [createdId], log)
: [];
await fs.rm(runRoot, { recursive: true, force: true });
if (remaining.length > 0) {
throw new CleanupFailedError(
remaining,
remaining.map((id) => cleanupCommand(id, options.auth)).join('\n '),
workError,
);
}
if (workError) rethrow(workError);
if (!result) throw new Error('Verification did not produce a result.');
return result;
}

Stage downloaded bundles safely

Extract into a fresh sandbox while rejecting traversal, links, special files, overwrites, and configured size limits. Retrieved content is never executed.

src/bundle.ts
import fs from 'node:fs/promises';
import path from 'node:path';
import yauzl, { type Entry, type ZipFile } from 'yauzl';

export interface BundleLimits {
maxEntries: number;
maxFileBytes: number;
maxTotalBytes: number;
}

export const DEFAULT_LIMITS: BundleLimits = {
maxEntries: 100,
maxFileBytes: 2 * 1024 * 1024,
maxTotalBytes: 10 * 1024 * 1024,
};

export async function readBundle(filePath: string) {
const resolved = path.resolve(filePath);
const stats = await fs.stat(resolved);
if (!stats.isFile()) {
throw new Error('Bundle must be a SKILL.md, .zip, or .skill file.');
}
const fileName = path.basename(resolved);
if (
fileName !== 'SKILL.md' &&
!fileName.endsWith('.zip') &&
!fileName.endsWith('.skill')
) {
throw new Error('Bundle must be named SKILL.md or end in .zip or .skill.');
}
return { fileName, content: new Uint8Array(await fs.readFile(resolved)) };
}

export async function readStream(
stream: ReadableStream<Uint8Array>,
maxBytes = DEFAULT_LIMITS.maxTotalBytes,
): Promise<Buffer> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Downloaded bundle exceeds ${maxBytes} bytes.`);
}
chunks.push(value);
}
return Buffer.concat(chunks);
}

function openZip(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true }, (error, zipFile) => {
if (error) reject(error);
else if (!zipFile) reject(new Error('Could not open downloaded bundle.'));
else resolve(zipFile);
});
});
}

function validateEntry(entry: Entry): {
relativePath: string;
directory: boolean;
} {
const relativePath = entry.fileName;
if (
!relativePath ||
relativePath.includes('\\') ||
relativePath.includes('\0') ||
path.posix.isAbsolute(relativePath) ||
path.posix.normalize(relativePath) !== relativePath ||
relativePath.split('/').includes('..')
) {
throw new Error(`Unsafe bundle path: ${JSON.stringify(relativePath)}`);
}
if ((entry.generalPurposeBitFlag & 0x1) !== 0) {
throw new Error(
`Encrypted bundle entries are not supported: ${relativePath}`,
);
}

const unixMode = entry.externalFileAttributes >>> 16;
const fileType = unixMode & 0o170000;
if (fileType === 0o120000) {
throw new Error(`Symbolic links are not allowed: ${relativePath}`);
}

const directory = relativePath.endsWith('/');
if (
fileType !== 0 &&
fileType !== 0o100000 &&
!(directory && fileType === 0o040000)
) {
throw new Error(
`Only regular files and directories are allowed: ${relativePath}`,
);
}
return { relativePath, directory };
}

function readEntry(
zipFile: ZipFile,
entry: Entry,
maxBytes: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
zipFile.openReadStream(entry, (error, stream) => {
if (error) {
reject(error);
return;
}
if (!stream) {
reject(new Error(`Could not read ${entry.fileName}.`));
return;
}
const chunks: Buffer[] = [];
let total = 0;
let settled = false;
stream.on('data', (chunk: Buffer) => {
total += chunk.byteLength;
if (total > maxBytes) {
settled = true;
stream.destroy();
reject(
new Error(`${entry.fileName} exceeds the per-file size limit.`),
);
return;
}
chunks.push(chunk);
});
stream.once('error', (error) => {
if (!settled) reject(error);
});
stream.once('end', () => {
if (!settled) resolve(Buffer.concat(chunks));
});
});
});
}

export async function stageDownloadedBundle(
archive: Buffer,
destination: string,
limits: BundleLimits = DEFAULT_LIMITS,
): Promise<string[]> {
const root = path.resolve(destination);
await fs.mkdir(path.dirname(root), { recursive: true });
await fs.mkdir(root, { recursive: false, mode: 0o700 });

const zipFile = await openZip(archive);
const staged: string[] = [];
let entries = 0;
let totalBytes = 0;

try {
await new Promise<void>((resolve, reject) => {
const fail = (error: unknown) => {
zipFile.close();
reject(error instanceof Error ? error : new Error(String(error)));
};

zipFile.once('error', fail);
zipFile.once('end', resolve);
zipFile.on('entry', (entry: Entry) => {
void (async () => {
entries += 1;
if (entries > limits.maxEntries) {
throw new Error(
`Bundle contains more than ${limits.maxEntries} entries.`,
);
}
const { relativePath, directory } = validateEntry(entry);
const target = path.resolve(root, relativePath);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`Bundle path escapes the sandbox: ${relativePath}`);
}
if (directory) {
await fs.mkdir(target, { recursive: true, mode: 0o700 });
} else {
if (entry.uncompressedSize > limits.maxFileBytes) {
throw new Error(
`${relativePath} exceeds the per-file size limit.`,
);
}
const content = await readEntry(
zipFile,
entry,
limits.maxFileBytes,
);
if (content.byteLength > limits.maxFileBytes) {
throw new Error(
`${relativePath} exceeds the per-file size limit.`,
);
}
totalBytes += content.byteLength;
if (totalBytes > limits.maxTotalBytes) {
throw new Error('Bundle exceeds the aggregate size limit.');
}
await fs.mkdir(path.dirname(target), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(target, content, { flag: 'wx', mode: 0o600 });
staged.push(relativePath);
}
zipFile.readEntry();
})().catch(fail);
});
zipFile.readEntry();
});
} catch (error) {
await fs.rm(root, { recursive: true, force: true });
throw error;
} finally {
zipFile.close();
}

if (!staged.some((file) => path.posix.basename(file) === 'SKILL.md')) {
await fs.rm(root, { recursive: true, force: true });
throw new Error('Downloaded bundle does not contain SKILL.md.');
}
return staged;
}
Your skill bundleSKILL.md, .zip, or .skill
Validationmetadata and normalized layout
Versioned Skills APIstable ID, advancing version
Fresh sandboxbounded zip extraction
Node.js 22.12.0 or newer
A Glean instance with the experimental Skills Platform APIs enabled
Your work email, or the complete Glean backend HTTPS origin
A tenant that permits the native skills:read and skills:write OAuth scopes; the legacy SKILLS compatibility scope or a user-scoped token is the fallback
The scaffold includes a sample SKILL.md at fixtures/sample-skill/SKILL.md; the later publish step can use that sample or your own bundle
1

Copy the project onto your machine

Copy the Skills versioning CLI, sample bundle, CI example, and credential-free fixture tests into a new directory. OAuth login and secure token storage come from the pinned @gleanwork/auth package.

npx -y tiged@2.12.8 gleanwork/glean-cookbook/recipes/skill-publishing-pipeline skill-publishing-pipeline
2

Install dependencies

cd skill-publishing-pipeline && npm install
3

Run the fixture tests

Run name-based supersession, version retrieval, scope fallback classification, and adversarial archive tests without credentials or network access.

npm test
4

Sign in with OAuth

Discover your Glean backend from work email and request skills:read and skills:write. Only a recognized scope-grant failure triggers one retry with legacy SKILLS. If OAuth is not available, skip this command: copy .env.example to .env and fill GLEAN_API_TOKEN and GLEAN_SERVER_URL.

npm run login -- --email "<work-email>"
5

Pass an explicit backend if you need one

If email discovery is wrong, pass --server-url with the complete Glean backend HTTPS origin on login, verify, and start. If DCR is restricted, export GLEAN_OAUTH_CLIENT_ID in your shell before npm run login. npm run login does not read .env, so do not store that client id only in .env.

6

Verify versioning on your instance

Create a cryptographically unique sample, publish a second version under the same name, retrieve and stage that version's content in a bounded sandbox, then permanently delete only the ID returned by this run. Success prints a Verified line that ends with cleanup completed.

npm run verify -- --email "<work-email>"
7

Publish a new version of your bundle

Validate fixtures/sample-skill/SKILL.md by default, confirm before name-based supersession if that display name already exists, and stage the returned zip under staged/ID/vVERSION.MINOR so a second publish does not overwrite the first. Pass --bundle with your own SKILL.md, .zip, or .skill, and --stage-dir to choose a different parent folder. The command prints the exact ID before staging, which you need for optional cleanup.

npm start -- publish --email "<work-email>"
8

Push this directory as a GitHub repository

Treat this scaffold directory as the GitHub repository root. The workflow at .github/workflows/publish-skill.yml is already in the scaffold and already points at fixtures/sample-skill/SKILL.md. Push this directory, then set a user-scoped token secret and backend-origin variable in that repository.

9

Delete the published skill only when you intend to

Deletion permanently removes every version. Pass only the exact ID printed by your publish run and read the confirmation prompt.

npm start -- cleanup --id "PASTE_THE_ID_PRINTED_BY_PUBLISH" --email "<work-email>"

Call validate first and stop on any error. Validation itself never creates or updates a skill.

If the validated display name already exists, show its ID and require confirmation before create adds a version.

Stage downloaded bundles in a fresh bounded sandbox, never overwrite files, and never execute bundle contents.

Deletion removes every version. Never choose a cleanup target by display name or a broad catalog search. If delete fails, do not report cleanup completed.

The scaffold opts in with includeExperimental, but the Skills endpoints may still be unavailable on a tenant or change before general availability.

The API stores and distributes skill bundles. This recipe does not run the skill or prove how an agent host will interpret it.

Native Skills scopes may not yet be grantable everywhere. The login wrapper uses the legacy compatibility scope only for a recognized scope-grant failure; a user-scoped token remains an explicit fallback.

Take it further
  • Use glean.skills.createVersion() when your system already tracks a specific skill ID and explicit version creation is clearer than name-based supersession.
  • PATCH a captured skill ID to enable or disable it without changing stored content.
  • Import and sync from GitHub in a later Skills recipe instead of folding that source of truth into this pipeline.

Publish a uniquely named sample skill twice, inspect the new version, then clean up the run-owned skill

Stdout ends with Verified <name> (<id>) at version <n>.<m>; cleanup completed. If delete fails, the process exits non-zero, prints the remaining ID and a cleanup command that includes --id, --yes, and the same --email or --server-url used for the run, and does not print that success line.

View source

Runs the recipe through the Glean cookbook plugin.

Auth

Run the authenticate step on this page. It discovers your tenant from work email and signs you in with OAuth, using the shipped login command. If OAuth is unavailable, create a scoped Glean-issued token in Token Management (skills:read, skills:write).

At a glance
CapabilitiesSkills
SurfacesPlatform API
StatusProduction pattern
Time~20 min
Required scopes
skills:readskills:write