Agent skill

Penguin SDK

by Prism-Shadow in Prism-Shadow/penguin-harness

A skill your agent uses whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app.

Apache-2.0Auto-check passedAI & LLM Engineering

Install Penguin SDK

skills CLI
$ npx skills add Prism-Shadow/penguin-harness --skill penguin-sdk -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install Prism-Shadow/penguin-harness penguin-sdk --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/Prism-Shadow/penguin-harness.git skills-src && mkdir -p .claude/skills && cp -r skills-src/plugins/agent-development/skills/penguin-sdk .claude/skills/penguin-sdk && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
penguin-sdk
GitHub stars
2.5k
Token cost
~11k tokens
SKILL.md length
4,561 words
Files
1
Skills in repo
31
Repo updated
First seen
Licence
Apache-2.0

At a glance

A skill your agent uses whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app.

  • Works in 2 steps: penguin config model add --root… → Environment variables cover the…
  • The user wants to build an agent application — their own program with an embedded agent
  • SKILL.md covers Before you start, Project location, Keys and the data root — check… and Setup, plus 6 more sections
  • Calls npm, pnpm and git; needs DEEPSEEK_API_KEY and OPENAI_API_KEY

What it does

Penguin SDK is an agent skill from Prism-Shadow/penguin-harness. Use whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app. This is writing application code on the Penguin Harness SDK, not configuring an Agent State inside PenguinHarness. Covers self-contained projects, the createSession/run streaming loop with thinking and image messages, wiring the user's existing tools in as CLI commands, and a complete RAG recipe that ingests documents into a knowledge base and answers with citations…

Its SKILL.md is about 11k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in AI & LLM Engineering, covering Frontend development, Building AI agents and Retrieval-augmented generation. The repository describes itself as: 🐧 Unified and Stable RSI Platform. The licence is Apache-2.0.

When your agent uses it

  • The user wants to build an agent application — their own program with an embedded agent
  • Such as an AI app
  • Workflows — the tabs and pages beside the chat that an Agent keeps in its own workflows/ folder inside PenguinHarness: building one
  • Restoring an earlier version

Example prompts

  • “/penguin-sdk”

Requirements

  • Node.js
  • A credential in DEEPSEEK_API_KEY
  • A credential in OPENAI_API_KEY

Workflow steps

2 steps, taken from the first numbered list in SKILL.md.

  1. penguin config model add --root --provider --model-id --api-key [--base-url ] [--client-type openai-chat] --set-default — prefer…
  2. Environment variables cover the credential only (DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, …), and only for an entry whose…

What it can do on your machine

Read from SKILL.md and the folder at commit d56d9ce. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • npm
    • pnpm
    • git
    • curl

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md. Its commands use npm, pnpm, git and curl, which can reach the network depending on how they are called.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names these keys or tokens, usually read from environment variables:

    • DEEPSEEK_API_KEY
    • OPENAI_API_KEY
    • ANTHROPIC_API_KEY

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Penguin SDK loads about 11k tokens when it runs. Until then it costs about 205 tokens; SKILL.md has 4,561 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~205
When it runs · the whole SKILL.md, loaded when a task matches
~11k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from Prism-Shadow/penguin-harness at commit d56d9ce, republished under its Apache-2.0 licence (© Prism-Shadow). 4,561 words, ~10,868 tokens.

Download SKILL.mdSave it as .claude/skills/penguin-sdk/SKILL.md (or your agent's skills folder).
name
penguin-sdk
description
Use whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app. This is writing application code on the Penguin Harness SDK, not configuring an Agent State inside PenguinHarness. Covers self-contained projects, the createSession/run streaming loop with thinking and image messages, wiring the user's existing tools in as CLI commands, and a complete RAG recipe that ingests documents into a knowledge base and answers with citations behind a web UI. Also use it for workflows — the tabs and pages beside the chat that an Agent keeps in its own `workflows/` folder inside PenguinHarness: building one, changing it, restoring an earlier version, and removing a tab or a whole workflow when the user wants the custom UI gone.

Penguin Harness SDK

@prismshadow/penguin-core is the TypeScript SDK this agent itself runs on. Use it to build your own AI apps:

  • An Agent loads its state (prompts, tools, skills) from <root>/<project_id>/agents/<agent_id>/. Creating an Agent whose directory is empty initializes it with defaults.
  • A Session is one conversation of an Agent inside a Workspace directory.
  • session.run() executes one task and streams every step (thinking, text, tool calls) as OmniMessages.

To have an agent perform a task, use the run_subagent tool — the SDK is for building applications, not for invoking agents.

Before you start

If the user's message only invokes this skill (e.g. "use penguin-sdk skill") without a concrete app to build, ask the user what they want to build. But when the request names a concrete goal — even a single sentence like "build a RAG app that answers questions about these docs" — do not ask follow-up questions: build it end to end with the defaults in this skill (self-contained workspace project, project default model, BM25 retrieval, web UI styled per the web-design skill) and list the assumptions you made in your final reply.

Project location

Create the app in the current workspace directory by default (the CWD value from your Environment section), as a self-contained project — do not place it under <app_data_dir> (PenguinHarness's app data root) or depend on any path outside the project folder. When creating the app's agent, the data root defaults under the working directory (CWD) too: point createAgent({ root }) at a directory inside the project, resolved from the source file so it stays relative:

ts
const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });

With every reference relative to the project, the user can move or copy the folder anywhere and it still runs.

Keys and the data root — check before you build

The app's Penguin data root must live inside the CWD workspace — never ~/.penguin. Point createAgent({ root }) and every penguin config ... --root <dir> at a directory under the current working directory (e.g. ./penguin_data); the global ~/.penguin belongs to the person running Penguin and must never hold — or lend — the app's config or keys.

Credential first, code second — a finished app that cannot answer is a failed delivery discovered too late. Before writing any code:

bash
env | grep -oE "(DEEPSEEK|OPENAI|ANTHROPIC|GEMINI)_API_KEY" || echo none

Only two sources count as a usable credential: a vault-injected environment variable (the check above; vault keys also appear in your Vault Keys section), or a key already configured in the app's own data root (penguin config model list --root <data_dir>). Keys in the global ~/.penguin or any other .penguin directory do not count — a bare penguin config model list (no --root) reads the global store, because the CLI defaults to the global root unless --root is given, so a key showing up there proves nothing for the app and must never be used or copied.

If neither counted source yields a key, stop immediately and ask the user to configure one — do not start building, and do not burn turns re-checking in a loop: have them open this agent's settings via the gear icon on its card (left side, Agents page) and add a model API key (e.g. DEEPSEEK_API_KEY) in the key vault tab — vault values reach your shell environment on the next task. One clear check, then hand back to the user. Build only after a credential is confirmed, or after clearly agreeing with the user to build now and verify later. Model ids to offer the user come from the penguin CLI catalog (penguin config model add --help) and the unified-llm-api skill's id table.

Setup

bash
npm install @prismshadow/penguin-core tsx

If the package is not on your npm registry (it is developed in the PenguinHarness monorepo and may not be published), develop inside a checkout of the PenguinHarness repo instead: add your app as a workspace package under packages/, depend on "@prismshadow/penguin-core": "workspace:*", then pnpm install && pnpm build at the repo root. Tell the user which route you took.

Configure a model for the app's data root, in this order — stop at the first that works:

  1. penguin config model add --root <data_dir> --provider <group> --model-id <id> --api-key <key> [--base-url <url>] [--client-type openai-chat] --set-default — prefer --client-type openai-chat --base-url <endpoint> (works with any OpenAI Chat Completions compatible endpoint; exact ids in the unified-llm-api skill). --provider is required: a model is always the (provider, model_id) pair and the group is never inferred from the id (custom for an endpoint outside the built-in groups).
  2. Environment variables cover the credential only (DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, …), and only for an entry whose requests go to the vendor's own endpoint (no --base-url, or the vendor's official one) — an entry with a --base-url of its own (a gateway, a self-hosted server) needs --api-key, or createSession refuses it. Model selection still comes from the project config, whose preset default is deepseek-flash. Env-only setup therefore works out of the box only with DEEPSEEK_API_KEY; for another vendor either run the CLI command above or pass a configured { provider, modelId } pair to createSession.

Keep model API keys project-local: configure them with the penguin CLI into the app's own data root under the working directory, so the project stays self-contained and movable. When building an AI app, always pass --root <data_dir> pointing at the app's data directory inside the current working directory (the same path you give createAgent({ root }), e.g. ./penguin_data) — never run penguin config ... without --root, or it writes to the global ~/.penguin/data instead of the project. Never read, copy or fall back to model keys stored in the user's global ~/.penguin directory — that config belongs to the person running Penguin, not to the app you are building.

Model config lives in one hidden file under the data root's project directory: .project_config.toml. It is CLI-only — never read, print or edit it.

If the user agreed to build before a credential exists, do not fake the verification: finish the build, report it as unverified, and point them at the key vault flow above — once a key is added, vault values reach your environment on the next task and you can run the self-test to completion.

Streaming loop

The raw run() stream mixes model, event and session-meta payloads — always narrow with the exported guards (isModelMessage, isCompleteModelMessage, isEventMessage) before touching payload.type; accessing msg.payload.type directly does not typecheck.

ts
import path from "node:path";
import readline from "node:readline/promises";
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";

const agent = await createAgent({ root: path.join(import.meta.dirname, "penguin_data") });
const session = await agent.createSession({ workspaceDir: process.cwd() });

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
for (;;) {
  const line = await rl.question("> ");
  if (!line.trim()) break;
  // One run per user turn; the same Session keeps the conversation context.
  for await (const msg of session.run([userText(line)], {
    approve: async () => "allow", // demo only — a real app should ask its user ("deny" blocks the call)
  })) {
    if (isModelMessage(msg)) {
      const p = msg.payload;
      if (p.type === "partial_text" && p.event_type === "delta") process.stdout.write(p.text);
      // CoT stream from reasoning models — show progress, but keep it out of the answer channel.
      if (p.type === "partial_thinking" && p.event_type === "delta") process.stderr.write(p.thinking);
    }
  }
  process.stdout.write("\n");
}
rl.close();
session.dispose();
  • createSession({ workspaceDir, provider, modelId }) — workspaceDir must already exist (omit for a temporary workspace); the model reference is the (provider, modelId) pair, so pass both to pick a configured model or neither for the project default — passing one alone throws.
  • The approve callback gates every tool call; omitting it denies everything.
  • opts.thinkingLevel ("none" | "low" | "medium" | "high" | "xhigh" | "max") overrides the agent's default (model.thinking_level in system_config.yaml) for this turn only — raise it for hard questions, drop it for latency-sensitive calls like titling or classification.
  • Session lifetime is the app's memory model: reuse one Session for a stateful chat (context accumulates, as above), create one per request for stateless QA (the RAG recipe below); either way call session.dispose() when done to release background processes.
  • An Agent's behavior is edited in its agent_state/ files (system_config.yaml, AGENTS.md, skills/), not in code.

Thinking and image messages

Modern models think before answering and accept images; the stream and the input protocol carry both — use them instead of flattening everything to text.

Thinking (CoT) out. Reasoning models stream partial_thinking (field thinking) before any partial_text, and a complete thinking message follows. Show the stream — a silent 20-second wait reads as a hang — but keep it in its own channel: a collapsible muted block per the web-design skill, auto-collapsed once answer text starts. Never concatenate thinking into the answer, store it as the answer, or cite from it; ignore its fidelity field (core's replay bookkeeping). Non-reasoning models simply never emit it — don't reserve UI space.

Images in. Build image input with imageUrlMessage (a web URL or a base64 data URL) beside userText in the same run input:

ts
import { imageUrlMessage, userText } from "@prismshadow/penguin-core";
session.run([userText(question), ...images.map(imageUrlMessage)], { ... });

Browser flow: <input type="file" accept="image/*"> plus paste/drag-drop → FileReader.readAsDataURL → POST { question, images: [dataUrl] } → the server maps each entry to imageUrlMessage. Reject non-image MIME types and cap size (a data URL rides the context window; a few MB is plenty). Whether the session model actually sees pixels is the model config's vision flag (penguin config model list prints vision=Y/-; set via --vision/--no-vision on model add, default supported): with vision=false the core folds the image into an [attached image: <path>] line and the built-in read_file tool reads it through the project's configured vision_model (penguin config model vision --provider <group> --model-id <id> --root <data_dir>) — the app still works, through a description instead of direct sight.

Other payloads worth handling (always narrow with the guards first): partial_tool_call / partial_tool_call_output — surface as an activity line ("running search…") in apps that grant tools; request_end (event) — a non-completed status is the error signal (auth → ask for a key; message carries the failure detail; retry_in_ms announces a planned in-run retry, renderable as a countdown); token_usage (event) — session-cumulative and last-request counts, if the app shows cost; compaction_begin / compaction_end (events) — long-lived chats only, show a brief "context being compacted" notice. Everything else is safe to ignore.

Wiring in the user's tools

When the app's agent must call the user's existing tools (scripts, internal CLIs, anything with an entry point), integrate them as CLI commands first: wrap each one as a small executable inside the project (a script under tools/, or the user's own binary), and describe it in the embedded agent's persona / AGENTS.md — name, what it does, one usage line. The agent invokes it through the built-in exec_command tool, so there is nothing to register: no schema to declare, arguments are flags, stdout is the result, the approve callback still gates every invocation, and the same command stays testable by hand.

Add an MCP server (tools.mcpServers in system_config.yaml) only when a CLI wrapper cannot express the integration — a long-lived authenticated connection, or tool schemas the model must see typed. Otherwise the CLI form is the cheaper default and keeps the project self-contained.

RAG knowledge app

The default recipe when the user wants an app that answers questions over a document set ("docs QA", "knowledge base", "chat with our docs", "become an expert on X"). The core contributes the agent loop only — retrieval is app code. Default to lexical BM25: no extra dependencies, no embedding credential, works offline. (Semantic upgrade: embed chunks via @prismshadow/mmsp — see the unified-llm-api skill — and rank by cosine; only when an embedding-capable key is configured.)

my-app/
  package.json       # "type": "module"; scripts: ingest / start
  persona.md         # the embedded agent's role — write it per the agent-initialization skill
  ingest.ts          # corpus/ → data/index.json; initializes penguin_data/, installs persona
  rag.ts             # BM25 retrieval over the chunk index
  server.ts          # POST /api/ask streams SSE; serves public/
  public/index.html  # chat UI — build it per the web-design skill
  corpus/            # collected source documents
  data/index.json    # generated chunk index
  penguin_data/      # agent data root (generated; model config lives here)

Collect — clone or fetch the sources into corpus/, keeping only text formats:

bash
git clone --depth 1 <repo_url> corpus/<name>   # or curl pages into corpus/
find corpus -type f ! -regex '.*\.\(md\|mdx\|txt\|html?\)$' -delete && rm -rf corpus/*/.git

Ingest (ingest.ts) — split on markdown headings, cap chunk size, write one JSON index; also initialize penguin_data/, install the persona and strip the skills the embedded agent doesn't need:

ts
import fs from "node:fs";
import path from "node:path";
import { createAgent } from "@prismshadow/penguin-core";

const ROOT = import.meta.dirname;
const walk = (d: string): string[] =>
  fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
    e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);

const STATE = path.join(
  ROOT, "penguin_data", "default_project", "agents", "default_agent", "agent_state");
await createAgent({ root: path.join(ROOT, "penguin_data") });
fs.copyFileSync(path.join(ROOT, "persona.md"), path.join(STATE, "AGENTS.md"));
// A fresh default_agent is initialized with the whole built-in Skill library, and every installed
// Skill's metadata is injected into the system prompt of every /api/ask. This app only answers
// from retrieved context, so remove them: unrelated skill descriptions cost tokens on each
// question and pull the answer off-topic when one happens to match the wording of a question.
fs.rmSync(path.join(STATE, "skills"), { recursive: true, force: true });

const chunks: { id: number; source: string; heading: string; text: string }[] = [];
for (const f of walk(path.join(ROOT, "corpus")).filter((f) => /\.(md|mdx|txt|html?)$/i.test(f))) {
  const raw = fs.readFileSync(f, "utf8");
  const text = /\.html?$/i.test(f) ? raw.replace(/<[^>]+>/g, " ") : raw;
  const source = path.relative(ROOT, f);
  let heading = path.basename(f);
  for (const block of text.split(/^(?=#{1,3} )/m)) {
    heading = block.match(/^#{1,3} (.+)/)?.[1] ?? heading;
    for (let i = 0; i < block.length; i += 1500) {
      const piece = block.slice(i, i + 1500).trim();
      if (piece.length > 40) chunks.push({ id: chunks.length, source, heading, text: piece });
    }
  }
}
fs.mkdirSync(path.join(ROOT, "data"), { recursive: true });
fs.writeFileSync(path.join(ROOT, "data", "index.json"), JSON.stringify(chunks));
console.log(`indexed ${chunks.length} chunks`);

Retrieve (rag.ts) — standard BM25 (k1 = 1.2, b = 0.75); the tokenizer treats each CJK character as a token so Chinese queries work. The corpus-wide statistics (per-chunk term frequencies, document frequencies, average length) never change once the corpus is indexed, so build them once in loadIndex — a per-query rescan would make every question O(corpus):

ts
import fs from "node:fs";
import path from "node:path";

export interface Chunk { id: number; source: string; heading: string; text: string }
export interface Index {
  chunks: Chunk[];
  tf: Map<string, number>[]; // per-chunk term → count
  len: number[];             // per-chunk token length
  df: Map<string, number>;   // term → number of chunks containing it
  avg: number;               // mean chunk length (BM25 length normalization)
}

const tokenize = (s: string): string[] => s.toLowerCase().match(/[a-z0-9]+|[一-鿿]/g) ?? [];

export function loadIndex(): Index {
  const chunks: Chunk[] = JSON.parse(
    fs.readFileSync(path.join(import.meta.dirname, "data", "index.json"), "utf8"));
  const tf: Map<string, number>[] = [];
  const len: number[] = [];
  const df = new Map<string, number>();
  for (const c of chunks) {
    const toks = tokenize(`${c.heading} ${c.text}`);
    const m = new Map<string, number>();
    for (const t of toks) m.set(t, (m.get(t) ?? 0) + 1);
    for (const t of m.keys()) df.set(t, (df.get(t) ?? 0) + 1);
    tf.push(m);
    len.push(toks.length);
  }
  const avg = len.reduce((n, l) => n + l, 0) / Math.max(len.length, 1);
  return { chunks, tf, len, df, avg };
}

export function search(index: Index, query: string, k = 6): Chunk[] {
  const { chunks, tf, len, df, avg } = index;
  const q = [...new Set(tokenize(query))];
  const score = (i: number): number => {
    let s = 0;
    for (const t of q) {
      const f = tf[i]!.get(t) ?? 0;
      if (f === 0) continue;
      const n = df.get(t) ?? 0;
      s += Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5)) *
        (f * 2.2) / (f + 1.2 * (0.25 + (0.75 * len[i]!) / avg));
    }
    return s;
  };
  return chunks.map((_, i) => [score(i), i] as const)
    .filter(([s]) => s > 0).sort((a, b) => b[0] - a[0]).slice(0, k)
    .map(([, i]) => chunks[i]!);
}

Answer & serve (server.ts) — one Session per request (stateless QA), retrieved chunks numbered into the prompt, deltas streamed over SSE, sources sent as the final event. A pure QA session needs no tool calls — deny every approval; a denied or tool-less turn terminates normally. Do not clear the toolset with tools: { builtin: [] }: an empty tools array is sent to the provider verbatim and some OpenAI-compatible endpoints reject it with a 400, which surfaces as a silent empty answer. Guard the request boundary — a malformed body must return 400, never reject the async handler (an unhandled rejection takes the whole server down) — and abort the run if the client disconnects mid-answer so you stop generating (and paying) for a page nobody is reading.

ts
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { createAgent, isModelMessage, userText } from "@prismshadow/penguin-core";
import { loadIndex, search } from "./rag.ts";

const ROOT = import.meta.dirname;
const PUB = path.join(ROOT, "public");
const agent = await createAgent({ root: path.join(ROOT, "penguin_data") });
const index = loadIndex();
const MIME: Record<string, string> = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" };

http.createServer(async (req, res) => {
  res.on("error", () => {}); // a client that vanishes mid-write must not throw an uncaught EPIPE
  if (req.method === "POST" && req.url === "/api/ask") {
    let question: string;
    try {
      let body = "";
      for await (const part of req) body += part; // a mid-body connection reset rejects here — caught below, never fatal
      const parsed = JSON.parse(body) as { question?: unknown };
      if (typeof parsed.question !== "string" || !parsed.question.trim()) throw new Error();
      question = parsed.question;
    } catch {
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "expected a JSON body { question: string }" }));
      return;
    }
    const hits = search(index, question);
    const context = hits.map((c, i) => `[${i + 1}] ${c.source} — ${c.heading}\n${c.text}`).join("\n\n");
    const ac = new AbortController();
    res.on("close", () => ac.abort()); // client navigated away → cancel the in-flight generation
    // Create the Session BEFORE committing headers: a model-config failure then returns a real
    // HTTP error instead of an unhandled rejection with a 200 already on the wire.
    let session;
    try {
      session = await agent.createSession({ workspaceDir: ROOT });
    } catch {
      res.writeHead(503, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "no model configured yet — see the setup steps" }));
      return;
    }
    res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
    try {
      const prompt = `Answer in plain text (no Markdown; short paragraphs) from the context below; cite blocks inline as [1][2]. If the context is not enough, say so.\n\n${context}\n\nQuestion: ${question}`;
      for await (const msg of session.run([userText(prompt)], { approve: async () => "deny", signal: ac.signal })) {
        if (isModelMessage(msg)) {
          const p = msg.payload;
          if (p.type === "partial_text" && p.event_type === "delta" && !res.writableEnded)
            res.write(`data: ${JSON.stringify({ delta: p.text })}\n\n`);
          // Reasoning models: forward CoT on its own SSE field so the UI can collapse it.
          if (p.type === "partial_thinking" && p.event_type === "delta" && !res.writableEnded)
            res.write(`data: ${JSON.stringify({ thinking: p.thinking })}\n\n`);
        }
      }
      // Sources carry the matched chunk text verbatim: the UI must be able to show the exact
      // block behind each [n], not just a file link.
      if (!res.writableEnded)
        res.write(`data: ${JSON.stringify({ sources: hits.map((c) => ({ source: c.source, heading: c.heading, url: `/${c.source}`, text: c.text })) })}\n\n`);
    } catch {
      // The run failed after headers were sent, or the client left: surface an error event (best effort), then clean up.
      if (!res.writableEnded) res.write(`data: ${JSON.stringify({ error: "generation failed" })}\n\n`);
    } finally {
      session.dispose();
      if (!res.writableEnded) res.end();
    }
    return;
  }
  const pathname = (req.url ?? "/").split("?")[0] ?? "/";
  // /corpus/* serves the source documents read-only, so citation links resolve to real files.
  const inCorpus = pathname.startsWith("/corpus/");
  const base = inCorpus ? path.join(ROOT, "corpus") : PUB;
  const rel = inCorpus
    ? pathname.slice("/corpus/".length)
    : pathname === "/"
      ? "index.html"
      : pathname.slice(1);
  const file = path.normalize(path.join(base, rel));
  if (file.startsWith(base + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) {
    res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "text/plain" });
    res.end(fs.readFileSync(file));
  } else {
    res.writeHead(404);
    res.end();
  }
}).listen(Number(process.env.PORT ?? 4630), () => console.log("http://localhost:4630"));

UI (public/index.html) — a chat interface built per the web-design skill: message list, streamed assistant text appended delta by delta (plain text under the output contract below: escape, split blank-line paragraphs, style the [n] markers), thinking events into the collapsible reasoning block (collapse it when the first answer delta arrives), the final sources event rendered as citations (pill chips or accordion source cards), an empty state inviting the first question with 3–4 example questions the corpus can actually answer (clicking one submits it), and a visible error state when /api/ask fails. Citations must satisfy both of these, never bare text:

  • Reveal the original chunk: clicking a citation chip (or an inline [n]) opens a popover/panel showing the matched chunk's text from the sources event verbatim — the numbering maps 1:1 to the context blocks in the prompt, so [n] always reveals exactly the block the answer drew on.
  • Link to the real document: inside the popover, <a href="<url>" target="_blank"> using the url field (/corpus/<path>, which this server serves) — clicking the chip itself opens the popover, the document link lives within it. When the corpus was cloned from a public repository, prefer mapping the path to the canonical upstream page instead (e.g. the GitHub blob URL derived from the clone URL).

Output format and language — settle both up front, in the persona and the retriever, not in the UI:

  • No Markdown pipeline — set the output format instead: instruct the embedded agent (in persona.md and the per-request prompt) to answer in plain text — short paragraphs separated by blank lines, citations as bare [n], no Markdown syntax. The UI then only escapes the text, splits paragraphs and styles the [n] markers; there is no renderer to build. When richer structure genuinely matters, have the model emit a small whitelisted HTML subset (<p> <ul> <li> <strong> <code>) and sanitize to exactly that whitelist before inserting — never inject unsanitized model output.
  • Cross-language retrieval: the corpus and the user often speak different languages (English docs, Chinese questions), and BM25 is purely lexical — a Chinese question scores zero against English chunks. At ingest time derive a small bilingual keyword map for the corpus's core vocabulary (10–20 domain terms, e.g. 权限 → permissions / allow / deny, 钩子 → hooks) and expand query tokens through it in search() before scoring; keep the per-character CJK tokenizer. The persona already pins the answer language to the question's language.

Persona (persona.md) — the embedded agent's role, written per the agent-initialization skill. Shape: one role sentence ("You are an expert on X; you answer strictly from the provided context blocks"), citation and refusal rules, plain-text output (no Markdown — the output contract above), answer language follows the question.

Show full SKILL.md (2,207 more words)Show less

Workflows: pages and server code the Agent keeps for itself

Inside PenguinHarness an Agent can hold workflows: small plugin packages in its own directory, written in TypeScript, that the server boots as module trees, shows as tabs beside the chat, reloads on every file change, and versions so any edit can be undone. This is the same module mechanism the server itself is built from — manifests as data, everything checked before any code runs — so a workflow that does not type-check, that was written against an interface version this server no longer fits, or whose manifests do not hold together fails to load with the problem named, while the previous version keeps serving.

Start here — the whole loop is files. You need nothing but your file tools: no HTTP API, no port, no login, no server source. Do not go looking for the running server, its bundle or a checkout of the repository, and do not probe its API — everything you need to know is written into the workflow folder by the server itself.

  1. Where: your Agent directory is <App Data Dir>/agents/<Agent ID>/ — both values are in the Environment section of your system prompt. Workflows live in its workflows/ folder (create it if it is missing); ls it to see what already exists before you add or change anything.
  2. Scaffold: write the three files below — package.json, a minimal index.ts, one page under ui/. The server notices the folder within a second or two.
  3. Read what the server wrote back: .build/status.json is the result of the last load — { ok, revision, checkedAt, error, tabs, hints }. ok: false carries the compiler's or the checker's message with file, line and reason; fix that and look again. ok: true with an empty tabs means NOTHING shows in the user's chat page, however finished the page under ui/ is: a page is only a file until package.json contributes a tab for it (see Tabs are contributions below), and hints spells out the entry to add. .harness/README.md is this same contract in the harness's own words — trust it over this skill if the two ever differ. .harness/plugin.d.ts is the exact WorkflowHost and WorkflowMain THIS harness offers — read it once instead of guessing at an interface.
  4. Iterate: edit, wait a moment, read .build/status.json again. checkedAt changing tells you the server saw your edit. When it says ok: true, lists your tab under tabs and has no hints, the tab is already in the user's chat page — and not before. If status.json never appears, ask the user to open this Agent's chat page once (that is what starts the server watching the folder) rather than hunting for another way in.

What you do not need to find out by experiment: host.run resolves as soon as the turn has STARTED (not when it ends) with { sessionId, queued }; host.sessionStatus(id) returns "idle", "running" or "compacting", so a run is finished when its Session is back to "idle"; host.getState() is synchronous and returns whatever was last passed to setState (null before the first one).

Layout, under <root>/<project_id>/agents/<agent_id>/workflows/<workflow_id>/ (beside agent_state/):

package.json    "type": "module" and "penguin": { "modules": [ …manifests ] } — no dependency to install
index.ts        default export { modules: { <Name>: { create(ctx) } } } satisfies WorkflowPackage — TypeScript only
ui/             the workflow's pages and their assets; which of them are tabs is what the manifest contributes
state.json      the workflow's own document, kept by the server across reloads and rollbacks
.harness/       the server's: the types this workflow was written against (plugin.d.ts) and their interface table — read plugin.d.ts, never edit it
.build/         the server's: emitted JavaScript per revision, and status.json — the result of the last load; read it, never edit it

The root manifest is named Workflow; it requires the host, provides the handler, and contributes its tabs:

json
{
  "name": "demo",
  "version": "0.1.0",
  "type": "module",
  "penguin": {
    "modules": [
      {
        "name": "Workflow",
        "requires": { "host": { "iface": "@prismshadow/penguin-server#WorkflowHost", "from": "Host" } },
        "provides": { "main": "@prismshadow/penguin-server#WorkflowMain" },
        "contributes": {
          "WebModule.sessionTabs": [
            { "id": "demo.main", "key": "main", "title": "Demo", "titleZh": "演示", "renderer": { "iframe": { "src": "ui/index.html" } } }
          ]
        }
      }
    ]
  }
}
ts
import type { WorkflowPackage } from "@prismshadow/penguin-server/plugin";

export default {
  modules: {
    Workflow: {
      create({ use }) {
        const host = use.host;
        return {
          api: {
            main: {
              async handle(req) {
                // req = { method, path, query, body }; path is below the workflow's api/ mount
                if (req.path === "/ask" && req.method === "POST") {
                  const { question } = req.body as { question: string };
                  // The same two verbs as above: open a Session, run a turn in it.
                  const { sessionId } = await host.createSession();
                  await host.run(sessionId, [{ text: question }]);
                  return { body: { sessionId } };
                }
                return { status: 404, body: { error: "no such route" } };
              },
            },
          },
        };
      },
    },
  },
} satisfies WorkflowPackage;

TypeScript, checked by the server. There is no build step to run, no tsconfig.json to write and nothing to npm install: the server builds one program from index.ts (and the .ts files it imports, with .js in the import specifier as NodeNext asks) under options it fixes itself — strict among them — refuses the load on any diagnostic, reporting file, line and reason, and otherwise emits into .build/<revision>/ and imports that. satisfies WorkflowPackage is what types use.host and req; without it strict refuses the untyped parameters. A folder holding index.js or index.mjs instead is refused outright.

Types come from the harness that runs you. One machine can run several harnesses — a release, a checkout, a platform someone pushed with interfaces of its own — and none of them is a version on npm, so @prismshadow/penguin-server/plugin is not a package here: the first time the server loads the folder it writes .harness/plugin.d.ts, rendered from ITS OWN interface table, and that is what the import resolves to. Create package.json and a minimal index.ts, let the server load it once, then read .harness/plugin.d.ts for the exact WorkflowHost this harness offers. The server then leaves .harness/ alone: it is the record of what the workflow was written against, and a later generation of the platform is COMPARED with it, by the TypeScript compiler, both for what the workflow requires and what it provides — a host method you rely on that has since gone is a load error naming it, not a failure on the first call. To move a workflow onto the harness that runs it now, delete .harness/ and fix what the compiler then reports.

Tabs are contributions. Each entry under WebModule.sessionTabs is one tab beside the chat: key (unique in the workflow, part of the full-page URL), title / titleZh, and a renderer whose iframe.src is a file under ui/. Several entries make several tabs; none makes a server-only workflow. It is the same slot, written the same way, a plugin contributes to — the host opens it to workflows and scopes the tab to this Agent. A slot the host has not opened (WebModule.pages, say) is refused by name.

WorkflowHost (published as module Host) speaks the SDK's verbs, scoped to the workflow's Project: listAgents() returns the Project's Agents as { agentId }[]; createSession({ agentId? }) opens a Session of this Agent — or of another Agent of the same Project — and returns { sessionId }; run(sessionId, [{ text: "…" }]) — the SDK's session.run, its input items spelled out because a workflow has no package to import userText from — runs one turn in a Session, new or existing, and returns { sessionId, queued } once it has started (a busy Session queues it as a follow-up; the Agent hears it as a message from the server, not from a person); sessionStatus(sessionId) says idle / running / …; getState() / setState(doc) over state.json (getState() is unknown: narrow it); log(text). More modules may be listed in penguin.modules and named as children of Workflow, with their own requires between them — the tree is checked as a whole.

HTTP, all under /api/projects/:projectId/agents/:agentId/workflows (Project members only): GET / lists the workflows with their revision, uiRev, tabs (each page's URL resolved) and current load error; GET /:id/ui/* serves a file of ui/ (there is no default document — a tab names its page); any method on /:id/api/* reaches handle — JSON by default in both directions (req.body parsed, { status?, body? } answered as JSON), and anything else when you say so: a non-JSON request body arrives as req.bytes (a Uint8Array; req.headers has its content-type, never the app's cookie), and a response that names a content-type in headers sends body as the string you wrote, or bytes for binary, or stream — an async iterable (an async function*) of strings or Uint8Arrays, each chunk written to the client as you yield it, which is how server-sent events and a model's token-by-token answer get through without waiting for the end (relay an upstream stream by yielding its chunks; a finally in the generator runs when the client goes away — though a generator parked inside an await only gets there once that await settles, so give a relay its own timeout rather than trusting the disconnect) — with location + a 3xx status for a redirect. That is how a handler serves a page it renders, an upload, a download, or the pages of a program it runs on this machine: fetch http://127.0.0.1:<port>/… from the HANDLER and return what came back — never put 127.0.0.1 in a page, because the page runs in the user's browser, which may be on another machine than you are; POST /:id/reload; GET /:id/history lists recorded versions; POST /:id/rollback { revision } restores one (code only — state.json stays) and reloads. DELETE /:id removes the workflow together with its recorded versions. From a page directly under ui/, call your handler with a relative fetch("../api/…"); the Web App shows each page in its tab and reloads it when uiRev changes.

Undoing, reverting, removing (撤销 / 还原 / 清掉界面). When the user asks to undo the UI, revert it, clean it up or get the chat page back the way it was, they are asking you to TAKE SOMETHING AWAY — never to build something. Do not answer such a request by adding a feature (a reset button, an undo stack, a new route). Work out which of the four below they mean; if it is not obvious from what they said, ask in one line before touching anything. Everything about a workflow is files in your own Agent directory, so each of these is a file operation you make yourself — list workflows/ first to see what exists. The server notices within a moment and the user's tabs follow without a refresh.

  1. "Get rid of it" / "back to before there was any custom UI" / 回到没定义 UI 的状态 — remove the workflow entirely: delete the folder workflows/<id>/ and its recorded versions workflows-history/<id>/. That is everything there is; the tabs disappear and nothing else of the Agent is touched. It cannot be undone, so say so in one line first, and never delete a workflow you were not asked to remove. (The user can do the same from the tab's bar: Remove, two clicks.)
  2. "Undo your last change" / "it was better before" — go back to an earlier version of the CODE: the recorded versions are full copies under workflows-history/<id>/<revision>/ (versions.json lists them, newest first); copy that version's files over the folder and the server reloads it. The tab's bar offers the same as History → Restore.
  3. "Remove that tab" / "I only want the first page" — delete its entry under WebModule.sessionTabs in package.json (and its page under ui/ if nothing else uses it). With "contributes": {} the workflow keeps its server code and has no UI at all.
  4. "Bring back the data" — you cannot: state.json is never part of a version (restoring code leaves the data as it is) and it has no history of its own. Say so plainly rather than inventing a recovery.

One constraint follows from 4, and it is a constraint on you, not a task to go and do: the workflow's data is only ever changed through host.setState from its handler. Never edit or delete state.json by hand — not to reset a board, not to fix a record — because what you overwrite is gone for good.

Your handler's responses are served from the app's own origin, so anything you echo back from a request is executed there: escape what you put into HTML, and treat a value that came in over /api/* as untrusted even though only Project members can reach it.

Theme. A workflow page is a separate document, so it inherits nothing from the app's stylesheet by itself. The Web App stamps light/dark on the page's root, copies its resolved palette (the gray scale, the accent pair, the font stack, the root font size) onto it, and injects /workflow-ui.css first in the head — a base stylesheet that styles plain HTML (headings, lists, forms, tables, code) to match the app and exposes --wf-bg, --wf-fg, --wf-muted, --wf-border, --wf-surface, --wf-accent, --wf-accent-fg, plus the classes wf-primary (a button), wf-card, wf-rows, wf-row, wf-muted. Write plain markup, take every colour and font from those variables, and the page follows the user through a theme or accent change; hardcode them and it clashes in one theme or the other. The page's own rules always win, and linking /workflow-ui.css yourself makes it look right when opened outside the app too.

Filling the app. A page can be shown as the whole app — no sidebar, no chat, no tab strip — at /app/<project>/<agent>/<workflow>[/<tab key>] (the workflow's first tab when no key is given): the tab's Fill the app button goes there, penguin web --app <project>/<agent>/<workflow>[/<tab key>] opens the browser straight onto it, and the page itself can ask with parent.postMessage({ type: "penguin:fill-app" }, "*"). The way back is the command palette — Ctrl+P or Ctrl+Shift+P (⌘ on macOS), both, so a page may take one of them for itself but never both — whose Exit full page lands on that Agent's chat.

Verify before you hand over

Never declare the app done without running it:

  1. npm install succeeds (or the workspace route builds).
  2. Model configured for penguin_data (CLI or env var; no usable key → see Setup: ask the user to add one to this agent's key vault, and report the app as unverified for now).
  3. npm run ingest prints indexed N chunks with N > 0.
  4. Start npm start in the background, then ask a real question: curl -N -sS -X POST localhost:4630/api/ask -H 'content-type: application/json' -d '{"question":"<something the corpus answers>"}' — expect streamed data: deltas ending in a sources event that carries source, url and the matched chunk text per hit. If nothing streams, the model call failed: re-check step 2 and the provider endpoint before touching the code. Then curl one of the returned source urls — it must return the document, not a 404 (citation links have to resolve).
  5. Open the UI (or screenshot it) to confirm the layout renders.

Fix any failure and re-verify; when the app accepts image input, one verification question must include a real image. Report with backtick-wrapped relative paths (server.ts, public/index.html, …), how to start the app, and the assumptions you made.

© Prism-Shadow, Apache-2.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in plugins/agent-development/skills/penguin-sdk of Prism-Shadow/penguin-harness.

Open the folder on GitHubat commit d56d9ce

Compare with similar skills

Penguin SDK next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Penguin SDK compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Penguin SDK this skillPrism-Shadow/penguin-harness2.5k—~11kAutomated safety check: PassApache-2.0
Gnogmickel/gno1151 repos~1.6kAutomated safety check: PassMIT
Gnogmickel/gno115—~11kAutomated safety check: PassMIT
RAG AssistantAtmosphere/atmosphere3.8k—~504Automated safety check: PassApache-2.0
Knowledge Queryevolution-foundation/evo-nexus545—~804Automated safety check: NotesCustom licence
Neo4j Document Import Skillneo4j-contrib/neo4j-skills114—~5.4kAutomated safety check: NotesMIT

Similar skills

  • Gno

    gmickel/gno

    Search local documents, files, notes, and knowledge bases. An agent skill from gmickel/gno.

    115 GitHub starsUsed in 1 repo~1.6k tokens
    Knowledge ManagementAuto-check passed
  • Gno

    gmickel/gno

    Search local documents, files, notes, and knowledge bases. An agent skill from gmickel/gno.

    115 GitHub stars~11k tokensUpdated yesterday
    Knowledge ManagementAuto-check passed
  • RAG Assistant

    Atmosphere/atmosphere

    Knowledge base assistant that retrieves and cites documents from a curated index.

    3.8k GitHub stars~504 tokensUpdated today
    AI & LLM EngineeringAuto-check passed
  • Knowledge Query

    evolution-foundation/evo-nexus

    Hybrid search (vector + BM25 via RRF + metadata boost) against the pgvector Knowledge base, with optional RAG synthesis.

    545 GitHub stars~804 tokensUpdated 4 mo ago
    AI & LLM EngineeringAuto-check: notes
  • Neo4j Document Import Skill

    neo4j-contrib/neo4j-skills

    Ingests unstructured and semi-structured documents into Neo4j as a knowledge graph.

    114 GitHub stars~5.4k tokensUpdated 2 days ago
    Knowledge ManagementAuto-check: notes
  • Blockify Integration

    iternal-technologies-partners/blockify-agentic-data-optimization

    Process documents with Blockify API to create optimized IdeaBlocks for RAG.

    315 GitHub stars~6.2k tokensUpdated 5 mo ago
    AI & LLM EngineeringAuto-check: notes

More from Prism-Shadow/penguin-harness

All 31 skills in this repo
  • A2ui

    Prism-Shadow/penguin-harness

    Make a reply easier to read and act on with rich blocks inside ordinary Markdown — a choice the user picks from, a form that collects several answers, a procedure as steps with warnings in place, a…

    2.5k GitHub stars~3k tokensUpdated yesterday
    Auto-check passed
  • Penguin Harness Dev

    Prism-Shadow/penguin-harness

    A skill your agent uses when developing PenguinHarness itself — changing packages/{core,server,web,cli,desktop,landing,docs,skills}, the built-in model catalog, the installers or the release…

    2.5k GitHub stars~3.4k tokensUpdated yesterday
    Auto-check passed
  • Bento Slides

    Prism-Shadow/penguin-harness

    Create and edit Bento presentations — self-contained .bento.html decks whose document is JSON.

    2.5k GitHub stars~1.6k tokensUpdated yesterday
    Auto-check passed
  • Penguin Harness Manual Test

    Prism-Shadow/penguin-harness

    A skill your agent uses when standing PenguinHarness up to try a change by hand — launching the Web App, the desktop shell, the landing page, the docs site or the component gallery to click through…

    2.5k GitHub stars~1.3k tokensUpdated yesterday
    Auto-check passed
  • Penguin Harness Frontend

    Prism-Shadow/penguin-harness

    A skill your agent uses when changing the PenguinHarness Web App (packages/web) or the shared UI package — adding or restyling any UI, picking a status colour, adding an icon, laying out a row or a…

    2.5k GitHub stars~6.4k tokensUpdated yesterday
    Auto-check passed
  • Browser Automation

    Prism-Shadow/penguin-harness

    Drive the PenguinHarness agent browser — the desktop app's built-in browser or the user's own Chrome — from the shell with penguin browser: open pages, read them as simplified HTML or text, act with…

    2.5k GitHub stars~2.9k tokensUpdated yesterday
    Auto-check: warnings

Questions about Penguin SDK

What does Penguin SDK do?

A skill your agent uses whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app. Penguin SDK is an agent skill from Prism-Shadow/penguin-harness. Use whenever the user wants to build an agent application — their own program with an embedded agent, such as an AI app, an agentic app or a RAG app.

When should I use Penguin SDK?

Penguin SDK fits situations like: the user wants to build an agent application — their own program with an embedded agent; such as an AI app; workflows — the tabs and pages beside the chat that an Agent keeps in its own workflows/ folder inside PenguinHarness: building one; restoring an earlier version.

How do I install Penguin SDK in Claude Code?

Run `npx skills add Prism-Shadow/penguin-harness --skill penguin-sdk -a claude-code`. Or copy the skill folder (plugins/agent-development/skills/penguin-sdk in Prism-Shadow/penguin-harness) into .claude/skills/penguin-sdk in your project. Claude Code loads it when a task matches its description.

How do I install Penguin SDK in Codex?

Run `npx skills add Prism-Shadow/penguin-harness --skill penguin-sdk -a codex`. Or copy the skill folder (plugins/agent-development/skills/penguin-sdk in Prism-Shadow/penguin-harness) into .agents/skills/penguin-sdk in your project. Codex loads it when a task matches its description.

Can I use Penguin SDK in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add Prism-Shadow/penguin-harness --skill penguin-sdk -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/penguin-sdk, .gemini/skills/penguin-sdk, .github/skills/penguin-sdk and .opencode/skills/penguin-sdk in your project.

What does Penguin SDK need to run?

Going by SKILL.md and its folder, Penguin SDK needs the command-line tools its instructions call (npm, pnpm, git and curl) and credentials named DEEPSEEK_API_KEY, OPENAI_API_KEY and ANTHROPIC_API_KEY. Our summary lists: Node.js; A credential in DEEPSEEK_API_KEY; A credential in OPENAI_API_KEY.

Does Penguin SDK access the network?

SKILL.md contains no URLs. Its commands use npm, git and curl, which can reach the network depending on how they are called. This is read from the text; nothing was executed.

Is Penguin SDK safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Penguin SDK use?

Penguin SDK is published under the Apache-2.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Penguin SDK use?

About 11k tokens (SKILL.md is roughly 43k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Penguin SDK?

Skills that share tags, products or a category with Penguin SDK: Gno (gmickel/gno, 115 stars), Gno (gmickel/gno, 115 stars), RAG Assistant (Atmosphere/atmosphere, 3.8k stars) and Knowledge Query (evolution-foundation/evo-nexus, 545 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Penguin SDK?

Prism-Shadow (a GitHub organization) maintains it in Prism-Shadow/penguin-harness, which has 2,464 GitHub stars. The repository holds 31 skills in this directory. The repository was last updated on October 7, 2026.

Source: Prism-Shadow/penguin-harness on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.