A first Skills integration should prove one successful persist against the reader's own instance: validate a local SKILL.md, create it once, and read it back. Hard-coding a shared demo corpus or jumping straight into versioning and zip sandboxing makes that first run harder than Search or Chat. This quickstart uses the official TypeScript API client for that first persist, then deletes only the skill ID this run created.
The runnable scaffold opts into the experimental Skills API through the official SDK, keeps validation separate from persistence, and treats downloaded latest content as untrusted bytes.
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.
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);
}
Validate, persist once, and retrieve latest content
Create a uniquely named skill, confirm it with list and get, download GET .../content without unpacking it, and delete only the captured skill ID.
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 { readSkillMd, readStream, saveLatestContent } 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: Cookbook first-persist verification for the Skills API.\n---\n\n# First persist verification\n\nThis fixture verifies a first Skills persist. 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()}`);
} else {
parts.push('--email <your-work-email>');
}
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); 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 {
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}.content`);
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 persisting 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}. This first persist does not add versions.`,
);
}
}
log('Publishing the skill once...');
const created = await api.create({ file: bundle });
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 latest skill content without unpacking it...');
const response = await api.retrieveContent(createdId);
const bytes = await readStream(response.result);
if (bytes.byteLength === 0) {
throw new Error('Latest skill content was empty.');
}
const isZip = bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
if (!bytes.toString('utf8').includes(displayName) && !isZip) {
throw new Error(
'Downloaded content does not include the published name.',
);
}
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;
}
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 login and secure token storage come from the pinned @gleanwork/auth package.
npx -y tiged@2.12.8 gleanwork/glean-cookbook/recipes/validate-and-publish-skill validate-and-publish-skill
Install dependencies
cd validate-and-publish-skill && npm install
Run the fixture tests
Run the Vitest fixture suite without credentials or network access, covering validation-before-create, a single persist, list/get, latest content download, and captured-ID cleanup.
npm test
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>"
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.
Verify against your instance
Validate a cryptographically unique SKILL.md, persist it once, confirm list/get/latest content, 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>"
Persist your local SKILL.md
Validate fixtures/sample-skill/SKILL.md, persist it once, then delete only that captured ID. Pass --bundle with your own SKILL.md to use a different file. This run still deletes the skill it creates; pass --yes when the terminal is not interactive.
npm start -- --email "<work-email>" --yes
Call validate first and stop on any error. Validation itself never creates or updates a skill.
This first run creates the skill a single time. Do not publish the same name again here; versioning is a later job.
Download latest content as bytes. Do not unpack archives or execute retrieved files.
Deletion removes the skill. 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.
- Use the Intermediate publishing recipe to prove name-based version supersession, inspect a specific version, and stage a downloaded zip in a bounded sandbox.
- Use the GitHub import recipe when the source of truth is a public repository rather than a local SKILL.md.
- Call glean.skills.createVersion() or PATCH enable/disable only after you already have a captured skill ID from a first persist.
Validate and publish a uniquely named sample skill, then delete the captured ID
Stdout ends with Verified <name> (<id>) at version <n>.<m>; downloaded <n> byte(s); 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.
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).