You want to confirm that a local SKILL.md can be published and retrieved through the Skills API. This quickstart first checks access with a generated sample, then tests your file. Each command compares the downloaded SKILL.md with the upload byte for byte and deletes the test skill. It does not extract or execute retrieved files.
The example uses the official SDK to validate your file before creating a skill. It retrieves the saved skill, compares the downloaded SKILL.md with the upload, and cleans up afterward. It never extracts files to disk or executes them.
Configure the Skills API client
Find your Glean instance, obtain credentials through the authentication library, and enable experimental endpoints in the SDK. Node.js loads optional environment settings; the recipe does not parse them itself.
import { loadEnvFile } from 'node:process';
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']);
export interface GleanClientTarget {
email?: string;
serverUrl?: string;
}
function loadDotEnv() {
try {
loadEnvFile();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
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.',
);
}
export async function createGleanClient(
target: GleanClientTarget,
log: (message: string) => void = () => undefined,
) {
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 = ['SKILLS'];
log('Using the OAuth session (SKILLS).');
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);
}
Create, retrieve, and delete a test skill
Validate the file, create a skill without automatic retries, and retrieve it by the returned ID. Compare downloaded content with the upload, then delete the test skill even if comparison fails. Stop without deleting if the API returns a later version of an existing skill.
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import type { Glean } from '@gleanwork/api-client';
import { PlatformProblemDetailError } from '@gleanwork/api-client/models/errors';
import { CleanupFailedError } from './errors.js';
import {
readSkillMd,
readStream,
saveLatestContent,
verifyDownloadedSkill,
} from './skill-md.js';
export type SkillsApi = Pick<
Glean['skills'],
'create' | 'delete' | 'list' | 'retrieve' | 'retrieveContent' | 'validate'
>;
export interface FirstPersistResult {
id: string;
displayName: string;
version: number;
minorVersion: number;
contentPath: string;
contentBytes: number;
}
function manifest(displayName: string) {
return `---\nname: ${displayName}\ndescription: Test publishing and retrieving a skill with the Skills API.\n---\n\n# Publishing test\n\nThis sample tests creating and retrieving a skill. It contains no executable code.\n`;
}
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()}`);
}
// With no flags, the retry uses the same .env/token path as the original run.
return parts.join(' ');
}
export function verifiedSuccessLine(result: FirstPersistResult) {
return `Verified ${result.displayName} (${result.id}) at version ${result.version}.${result.minorVersion}; downloaded ${result.contentBytes} byte(s); SKILL.md matches the upload; cleanup completed.`;
}
export async function findSkillById(api: SkillsApi, skillId: string) {
let cursor: string | undefined;
do {
const page = await api.list(100, cursor);
if (page.skills.some((skill) => skill.id === skillId)) return true;
cursor = page.next_cursor ?? undefined;
} while (cursor);
return false;
}
export async function findSkillByName(api: SkillsApi, displayName: string) {
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;
}
async function rejectInvalidFrontmatter(api: SkillsApi, runRoot: string) {
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 readSkillMd(invalidPath) });
} catch (error) {
if (
!(error instanceof PlatformProblemDetailError) ||
error.status !== 400 ||
![
'invalid_request',
'missing_required_field',
'invalid_parameter',
].includes(error.code)
) {
throw error;
}
rejected = true;
}
if (!rejected) throw new Error('Invalid SKILL.md unexpectedly validated.');
}
export async function verifyFirstPersist(
api: SkillsApi,
options: {
workDir: string;
cleanup: boolean;
bundlePath?: string;
auth?: { email?: string; serverUrl?: string };
log?: (message: string) => void;
},
): Promise<FirstPersistResult> {
const log = options.log ?? (() => undefined);
const uniqueName = `cookbook-validate-${randomBytes(8).toString('hex')}`;
const runRoot = path.join(options.workDir, uniqueName);
const skillPath = options.bundlePath
? path.resolve(options.bundlePath)
: path.join(runRoot, 'SKILL.md');
const contentPath = path.join(runRoot, 'downloaded', `${uniqueName}.zip`);
let createdId: string | undefined;
let result: FirstPersistResult | undefined;
let workError: unknown;
await fs.mkdir(runRoot, { recursive: true, mode: 0o700 });
if (!options.bundlePath) {
await fs.writeFile(skillPath, manifest(uniqueName), {
flag: 'wx',
mode: 0o600,
});
}
try {
log('Validating the local SKILL.md without saving it...');
const bundle = await readSkillMd(skillPath);
const validation = await api.validate({ file: bundle });
const displayName = validation.metadata.display_name;
if (!options.bundlePath && displayName !== uniqueName) {
throw new Error('Validation returned an unexpected skill name.');
}
log('Confirming invalid frontmatter is rejected without a create call...');
await rejectInvalidFrontmatter(api, runRoot);
if (options.bundlePath) {
const existing = await findSkillByName(api, displayName);
if (existing) {
throw new Error(
`A skill named "${displayName}" already exists as ${existing}. Choose an unused name; this quickstart does not add versions.`,
);
}
}
log('Publishing the skill once...');
// Create can add a version to an existing name. Never retry an ambiguous write.
const created = await api.create(
{ file: bundle },
{ retries: { strategy: 'none' } },
);
if (
created.skill.latest_version !== 1 ||
created.skill.latest_minor_version !== 1
) {
throw new Error(
`Create returned version ${created.skill.latest_version}.${created.skill.latest_minor_version} for ${created.skill.id}; expected a new skill at version 1.1. This may be an existing skill; it was not deleted. Inspect it before continuing.`,
);
}
createdId = created.skill.id;
if (created.skill.display_name !== displayName) {
throw new Error('Created skill name does not match validated metadata.');
}
log('Confirming list and get return the captured ID...');
if (!(await findSkillById(api, createdId))) {
throw new Error('List did not include the skill this run just created.');
}
const retrieved = await api.retrieve(createdId);
if (retrieved.skill.id !== createdId) {
throw new Error('Direct retrieval returned a different skill.');
}
log('Downloading the skill ZIP and comparing SKILL.md with the upload...');
const response = await api.retrieveContent(createdId);
const bytes = await readStream(response.result);
if (bytes.byteLength === 0) {
throw new Error('Latest skill content was empty.');
}
await verifyDownloadedSkill(bytes, bundle.content);
const saved = await saveLatestContent(bytes, contentPath);
result = {
id: createdId,
displayName: created.skill.display_name,
version: created.skill.latest_version,
minorVersion: created.skill.latest_minor_version,
contentPath: saved,
contentBytes: bytes.byteLength,
};
} 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;
}
Compare the stored file with the upload
Use yauzl to read the ZIP in memory. Require one regular SKILL.md at the root, bound decompression by the uploaded file size, check its checksum, and compare bytes. Do not compare ZIP bytes: compression and timestamps can differ.
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { crc32 } from 'node:zlib';
import { fromBufferPromise } from 'yauzl';
export const MAX_CONTENT_BYTES = 10 * 1024 * 1024;
export async function readSkillMd(filePath: string) {
const resolved = path.resolve(filePath);
const stats = await fs.stat(resolved);
if (!stats.isFile() || path.basename(resolved) !== 'SKILL.md') {
throw new Error('Provide a local SKILL.md file.');
}
return {
fileName: 'SKILL.md',
content: new Uint8Array(await fs.readFile(resolved)),
};
}
export async function readStream(
stream: { getReader(): ReadableStreamDefaultReader<Uint8Array> },
maxBytes = MAX_CONTENT_BYTES,
): 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 skill content exceeds ${maxBytes} bytes.`);
}
chunks.push(value);
}
return Buffer.concat(chunks);
}
// A single Markdown upload is returned as a ZIP with one root SKILL.md.
// Compare file bytes, not ZIP bytes: archive timestamps and compression can change.
export async function verifyDownloadedSkill(
archive: Buffer,
uploaded: Uint8Array,
): Promise<void> {
const zip = await fromBufferPromise(archive, {
lazyEntries: true,
strictFileNames: true,
});
try {
const layoutError =
'Downloaded bundle must contain exactly one regular SKILL.md file.';
if (zip.entryCount !== 1) throw new Error(layoutError);
for await (const entry of zip.eachEntry()) {
const fileType = (entry.externalFileAttributes >>> 16) & 0o170000;
if (
entry.fileName !== 'SKILL.md' ||
(entry.externalFileAttributes & 0x10) !== 0 || // DOS directory flag
(fileType !== 0 && fileType !== 0o100000)
) {
throw new Error(layoutError);
}
if (entry.uncompressedSize !== uploaded.byteLength) {
throw new Error(
'Downloaded SKILL.md does not match the uploaded file.',
);
}
const stream = await zip.openReadStreamPromise(entry);
const content = await readStream(
Readable.toWeb(stream),
uploaded.byteLength,
);
// yauzl validates sizes and decompression, but does not verify CRC-32.
if (crc32(content) !== entry.crc32) {
throw new Error('Downloaded SKILL.md failed its ZIP checksum.');
}
if (!content.equals(Buffer.from(uploaded))) {
throw new Error(
'Downloaded SKILL.md does not match the uploaded file.',
);
}
}
} finally {
zip.close();
}
}
export async function saveLatestContent(
bytes: Buffer,
destination: string,
): Promise<string> {
const resolved = path.resolve(destination);
await fs.mkdir(path.dirname(resolved), { recursive: true, mode: 0o700 });
await fs.writeFile(resolved, bytes, { flag: 'wx', mode: 0o600 });
return resolved;
}
Copy the project onto your machine
Copy the runnable TypeScript Skills CLI, sample SKILL.md, and credential-free fixture tests into a new directory. OAuth uses the official @gleanwork/auth package.
npx -y tiged@2.12.8 gleanwork/glean-cookbook/recipes/validate-and-publish-skill validate-and-publish-skill
Install dependencies
Enter the project directory and install its dependencies. Run the remaining commands from this directory.
cd validate-and-publish-skill && npm install
Run the fixture tests
Run the tests without real credentials or network access. Workflow tests use the real SDK with MSW HTTP handlers to check content comparison and failure handling. Unhandled requests fail. CLI smoke tests cover help and local argument handling. Passing these tests does not verify access to your Glean instance.
npm test
Choose an authentication path
OAuth is the default. @gleanwork/auth discovers your instance, registers the OAuth client dynamically, and stores credentials securely. If OAuth is unavailable, copy .env.example to .env, set GLEAN_SERVER_URL and a user-scoped GLEAN_API_TOKEN with the SKILLS permission, and skip the next step. Omit --email from the remaining commands. If email discovery finds the wrong instance, replace --email with --server-url and your complete backend HTTPS origin on the login and live commands.
Sign in with OAuth
Run the login command with the SKILLS scope and complete authorization in your browser. Wait for the terminal command to report success before continuing. Skip this step if you configured a token in .env.
npm run login -- --email "<work-email>"
Verify against your instance
Create a uniquely named sample skill, retrieve it, compare the downloaded SKILL.md byte for byte with the upload, and permanently delete it. This verifies authentication and Skills API access before you use your own file. A mismatch fails verification but still triggers cleanup. With token authentication, run npm run verify without --email.
npm run verify -- --email "<work-email>"
Test your own SKILL.md
Pass the path to your SKILL.md. Choose an unused name and do not publish that name concurrently. The command validates, creates, retrieves, compares, and then permanently deletes the test skill. With token authentication, omit --email.
npm start -- --bundle "<skill-path>" --email "<work-email>" --yes
Call validate first and stop on any error. Validation itself never creates or updates a skill.
The API can add a version when a name already exists. Use an unused name and avoid concurrent runs with that name. Creation is not retried automatically. If creation times out, inspect your instance before running it again; a skill may have been saved without returning its ID.
Limit the ZIP download to 10 MiB. Read its single regular root SKILL.md in memory, with decompression bounded by the uploaded file size. Require a valid checksum and exact file bytes. Do not extract files to disk or execute retrieved content.
Deletion is permanent. Clean up only the new skill identified by the create response, never a name-search result. New skills start at version 1.1. If the API returns any other version, stop without deleting it. If deletion fails, the command reports the remaining ID and exits with an error.
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 quickstart verifies the bytes of a single uploaded SKILL.md, not multi-file bundles or skill execution. It does not run the skill or test how an agent host interprets it.
Your instance must allow the SKILLS OAuth scope or the SKILLS permission on a user-scoped token.
- Use the skill-publishing-pipeline recipe to publish another version, inspect a specific version, and examine a downloaded archive safely.
- Use the GitHub import recipe when the source of truth is a public repository rather than a local SKILL.md.
- After you can create and retrieve a skill, use its ID to explore version management and enabling or disabling it.
Validate and test publishing a uniquely named sample skill, then delete the captured ID
Stdout ends with Verified <name> (<id>) at version 1.1; downloaded <n> byte(s); SKILL.md matches the upload; cleanup completed. The downloaded ZIP must contain one regular root SKILL.md whose checksum and bytes match the upload. Invalid archives or mismatches fail verification and still trigger cleanup. 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.
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).