Skip to main content

Some teams keep Agent Skills in GitHub rather than a local SKILL.md. Previewing that URL, importing only the selected skills, and syncing a captured ID is a different job from the first persist or name-based versioning. This recipe uses a public GitHub skill directory on the main branch so the run is answerable on the reader's tenant without a seeded corpus.

The runnable scaffold opts into the experimental Skills API through the official SDK, previews a GitHub URL without persisting a source, imports only selected URLs, and syncs one captured skill.

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);
}

Preview, import, and sync

Call previewSource with stream false by default, import the selected GitHub URL, sync that captured skill, and delete only IDs this run created.

src/workflow.ts
import type { Glean } from '@gleanwork/api-client';
import {
GleanBaseError,
PlatformProblemDetailError,
} from '@gleanwork/api-client/models/errors';
import { PreviewSourceAcceptEnum } from '@gleanwork/api-client/sdk/skills.js';
import type { PlatformSkillSourcePreviewResponse } from '@gleanwork/api-client/models/components';
import { CleanupFailedError, formatCliError } from './errors.js';
import { DEFAULT_SOURCE_URL } from './fixture.js';
import { parsePreviewResult } from './preview.js';

export type SkillsApi = Pick<
Glean['skills'],
'delete' | 'import' | 'list' | 'previewSource' | 'retrieve' | 'sync'
>;

export interface ImportResult {
ids: string[];
displayName: string;
sourceUrl: string;
commitSha: string;
updated: boolean;
}

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 importedSuccessLine(result: ImportResult) {
return `Imported ${result.displayName} (${result.ids.join(', ')}) from ${result.sourceUrl} at ${result.commitSha}; cleanup completed.`;
}

export function githubFetchStatusHint(status?: number) {
if (status === 400) {
return ' HTTP 400 means this GitHub URL or ref is not supported. Commit permalinks and 40-character SHA URLs are rejected. Use a branch or tag URL such as .../tree/main/....';
}
if (status === 503) {
return ' HTTP 503 means GitHub import is disabled or unavailable.';
}
if (status === 403) {
return ' HTTP 403 means this credential cannot import from GitHub.';
}
if (status === 429) {
return ' HTTP 429 means GitHub import is rate-limited. Wait and retry.';
}
return '';
}

function githubFetchError(error: unknown): Error {
const summary = formatCliError(error).error;
const status =
error instanceof PlatformProblemDetailError
? error.status
: error instanceof GleanBaseError
? error.statusCode
: undefined;
return new Error(
`GitHub import failed: ${summary}.${githubFetchStatusHint(status)} The import recipe fails rather than skipping.`,
);
}

function isSkillsApiNotFound(error: unknown) {
if (error instanceof PlatformProblemDetailError) return error.status === 404;
if (error instanceof GleanBaseError) return error.statusCode === 404;
return false;
}

function reraiseSourceError(error: unknown): never {
if (isSkillsApiNotFound(error)) throw error;
if (
error instanceof PlatformProblemDetailError ||
error instanceof GleanBaseError
) {
throw githubFetchError(error);
}
throw error instanceof Error ? error : new Error('Verification failed.');
}

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 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 resolvePreview(
api: SkillsApi,
sourceUrl: string,
stream: boolean,
log: (message: string) => void = () => undefined,
): Promise<PlatformSkillSourcePreviewResponse> {
try {
const preview = await api.previewSource(
{ source_url: sourceUrl, stream },
stream
? { acceptHeaderOverride: PreviewSourceAcceptEnum.textEventStream }
: undefined,
);
return parsePreviewResult(preview, log);
} catch (error) {
reraiseSourceError(error);
}
}

export async function importSkillFromGithub(
api: SkillsApi,
options: {
sourceUrl?: string;
stream?: boolean;
cleanup: boolean;
auth?: { email?: string; serverUrl?: string };
log?: (message: string) => void;
},
): Promise<ImportResult> {
const log = options.log ?? (() => undefined);
const sourceUrl = options.sourceUrl?.trim() || DEFAULT_SOURCE_URL;
const createdIds: string[] = [];
let result: ImportResult | undefined;
let workError: unknown;

try {
log(`Previewing ${sourceUrl} without persisting a source...`);
const preview = await resolvePreview(
api,
sourceUrl,
options.stream === true,
log,
);
const selected = preview.skills.at(0);
if (!selected) {
const failures = preview.failures
.map((failure) => `${failure.code}: ${failure.detail}`)
.join('; ');
throw new Error(
failures
? `GitHub preview returned no importable skills (${failures}).`
: 'GitHub preview returned no importable skills.',
);
}

log(`Importing ${selected.source_url}...`);
let imported;
try {
imported = await api.import({ source_urls: [selected.source_url] });
} catch (error) {
reraiseSourceError(error);
}
const skill = imported.skills.at(0);
if (!skill) {
throw new Error('Import returned no skills.');
}
createdIds.push(...imported.skills.map((item) => item.id));

log('Confirming get and list return the imported skill...');
const retrieved = await api.retrieve(skill.id);
if (retrieved.skill.id !== skill.id) {
throw new Error('Direct retrieval returned a different skill.');
}
if (!(await findSkillById(api, skill.id))) {
throw new Error('List did not include the skill this run just imported.');
}

log(`Syncing imported skill ${skill.id} from its stored GitHub URL...`);
let synced;
try {
synced = await api.sync(skill.id);
} catch (error) {
reraiseSourceError(error);
}

result = {
ids: [...createdIds],
displayName: retrieved.skill.display_name,
sourceUrl: selected.source_url,
commitSha: synced.commit_sha,
updated: synced.updated,
};
} catch (error) {
workError = error;
}

const remaining = options.cleanup
? await deleteCapturedIds(api, createdIds, log)
: [];
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;
}
Public GitHub URLskill-creator on main
Source previewno persisted source
Skills importselected URLs only
Syncrefresh one captured ID
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
Tenant-side GitHub source fetching enabled for Skills; the default source is the public skill-creator directory on main, and verification fails rather than skipping if the tenant cannot preview it
1

Copy the project onto your machine

Copy the runnable TypeScript GitHub import CLI and credential-free MSW 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/import-skill-from-github import-skill-from-github
2

Install dependencies

cd import-skill-from-github && npm install
3

Run the fixture tests

Run Vitest with recorded preview payloads and MSW, without GitHub or Glean credentials, covering JSON preview, optional SSE preview, import, sync, and captured-ID cleanup.

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 against your instance

Preview the public GitHub skill-creator directory on main, import the selected URL, sync that captured skill, confirm get and list, then permanently delete only IDs this run created. HTTP 400 means an unsupported GitHub URL or ref, including commit permalinks. HTTP 503 means GitHub import is disabled or unavailable. HTTP 403 means this credential cannot import. HTTP 429 means rate-limiting. Success prints an Imported line that ends with cleanup completed.

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

Watch repository scan progress

Run the same preview, import, and sync path with --stream. Scan events print as the tenant walks the GitHub directory. This command still deletes the captured skill when it finishes; there is no keep path. Pass --yes when the terminal is not interactive.

npm start -- --email "<work-email>" --yes --stream

Inspect the GitHub URL with previewSource first. Preview persists neither a source nor any skill.

Pass source URLs returned by preview. Import is atomic: if any selected URL cannot be fetched, validated, or persisted, no skills are created.

If this tenant cannot fetch GitHub, stop. Do not skip verification or treat an empty preview as success.

Cleanup deletes only IDs this run created. 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.

Preview, import, and sync require GitHub import. The default source is a branch URL because commit permalinks are unsupported. HTTP 400 means an unsupported URL or ref. HTTP 503 means GitHub import is disabled or unavailable. HTTP 403 means this credential cannot import. HTTP 429 means rate-limiting. Any of those statuses is a failed verify, not a skipped one.

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
  • Pass --source-url to preview a different public GitHub skill directory after the default branch source succeeds.
  • Use the Intermediate publishing recipe when the source of truth is a local bundle and you need version supersession or zip sandboxing.
  • Call glean.skills.createVersion() or PATCH enable/disable only after you already have a captured skill ID.

Preview, import, and sync the public GitHub skill-creator fixture, then delete run-owned IDs

Stdout ends with Imported skill-creator (<id>) from the branch GitHub URL at <sha>; cleanup completed. If GitHub import fails, the run fails with a GitHub-import error instead of skipping. HTTP 400 means an unsupported URL or ref, including commit permalinks. HTTP 503 means GitHub import is disabled or unavailable. HTTP 403 means the credential cannot import. HTTP 429 means rate-limiting. If delete fails, the process exits non-zero 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