How this skill is triggered — by the user, by Claude, or both
Slash command
/auriga:buildThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You help users build Auriga skills stored in Ion cloud storage.
You help users build Auriga skills stored in Ion cloud storage. A skill is a SKILL.md file plus optional Python scripts that run in a namespace-isolated sandbox.
Skills are stateful by default. Every skill persists
meaningful state to /skill/data/ so future invocations
remember context from earlier sessions. What to persist is
skill-specific — preferences, interaction history, learned
patterns, accumulated data. Load state at invocation start;
save updates at end. A skill with no memory is a missed
opportunity.
NEVER search the local filesystem for skills. All skill data
lives in Ion cloud storage. Use discover_skills() to list
skills, ion_list to browse files, ion_read to read them.
Use the MCP tools listed in this skill's tools: frontmatter
(create_draft, ion_write, publish_skill, etc.) to perform
every step of the build. If any of those tools appear missing
from your tool list, the MCP server failed to connect — STOP
and report "MCP server unavailable" to the user. Do NOT fall
back to RemoteTrigger, generic HTTP calls, or printing file
contents as instructions for the user to paste: those are not
substitutes for the MCP tools and will produce a broken build.
discover_skills() to check if it exists (shows
latest_version and has_draft per skill)create_draft(skill_name) to open a draft:
ion_list to see what's there, then pull
every file you need to inspect in a single
ion_read_batch(["/skills/user/<slug>/draft/**"]) call
-- don't loop ion_read. Use ion_write for isolated
edits, ion_write_batch for multi-file updates. Do NOT
rewrite files that are already correct.ion_write_batch (up to 100 files, 10 MiB per call).
Only fall back to ion_write for isolated single-file
edits -- looping ion_write hits per-IP rate limits.scripts/state.py or integrated into the main script)
and instruct the SKILL.md agent to load state at start
and save updates at end of each interaction.ion_read("/sys/docs/ion/google/{service}") (sheets,
calendar, gmail, drive, docs, slides)
ion_read("/sys/docs/ion/{module}") (vfs, output, flow,
scopes, oauth, cards, claude_chat)
Do NOT guess at function signatures.run_skill(skill_name, prompt) — iteratelicense field in SKILL.md, write the LICENSE
file to the draft before publishing. Publishing with a
license field but no LICENSE file emits a warning and
closes the draft, forcing a create_draft() + re-publish
cycle.publish_skill(skill_name) to releaseIMPORTANT: A skill is not ready for use until published. Always
call publish_skill(skill_name) after successful testing. Do not
end the build flow with a draft-only skill — users will see it
as unfinished. Publishing creates an immutable versioned snapshot
that automations and other users can rely on.
Semver (MAJOR.MINOR.PATCH) with draft/publish cycle. All
edits go to /skills/user/{slug}/draft/. Publishing copies draft
to an immutable versioned snapshot and deletes the draft.
First publish → 0.1.0; subsequent → auto patch bump.
To update a published skill: write to draft, test, publish.
Path resolution:
draft/ — current draftlatest/ — latest published version (falls back to draft
if unpublished)0.1.0/ — specific versionrun_skill prefers draft over latest for testing.
YAML frontmatter + markdown body (the skill agent's prompt).
schema_version: always "1.0.0" (required for publishing)name: kebab-case identifierdescription: one-line summaryPublishing will reject skills missing schema_version in
their SKILL.md frontmatter.
license: "Proprietary" for private/closed skills, or an
SPDX identifier (e.g. "MIT", "Apache-2.0") for open
source. Use "Custom" only if neither fits. When set,
include a LICENSE file in the skill root.
metadata: sort_order (int), icon (emoji)
welcome_cards: up to 6 starter cards rendered on the empty
state of any chat scoped to this skill (app mode, skill chat
tab). Each is {icon, label, prompt}; clicking auto-sends
prompt. icon is a Tabler name like "IconBolt" or an
emoji; label <=40 chars; prompt <=500 chars.
welcome_cards:
- icon: IconBolt
label: Summarize
prompt: Summarize my recent emails
- icon: 🔍
label: Search
prompt: Find recent docs about X
requires:
network: list of base URLs the skill can reachsecrets: list of {name} with optional provider,
signup_url, instructionsconfig: list of {name, instructions?}oauth_scopes: list of Google OAuth scope URIs.
Supported URIs (prefix each with
https://www.googleapis.com/auth/):
Calendar: calendar.events, calendar.events.readonly
Gmail: gmail.send
Sheets/Docs/Slides: drive.file
drive.file covers ALL Sheets, Docs, and Slides API
operations — both folder ops (list_, create_) and
ID-based ops (read_rows, append_rows, api()).
Calendar and Gmail have their own scopes above.vfs: list of {path, access} (read/write, /* globs).
Skills automatically have read+write access to their own
/skills/user/{slug}/* tree. requires.vfs is only for
non-skill paths (like /sys/channels/gchat/dm or
/sys/channels/mail/send).Populate signup_url and instructions on secrets so users
know how to obtain the key.
Skills accept a JSON dict of named arguments at invocation
(via run_skill, webhooks, or automations). No schema needed.
When provided, arguments are appended to the prompt and the
agent executes immediately; otherwise it runs interactively.
Skills that make HTTP requests MUST declare requires.network
and requires.secrets. Without them, requests fail (sandbox
has no network by default) and keys are unavailable.
Scripts read secrets via get_secret() from auriga.ion.vfs,
available at /run/secrets/<name> inside the sandbox.
Before testing, call list_secrets to verify the user has
required keys. Missing keys: direct to Settings > API Keys.
---
schema_version: "1.0.0"
name: weather-lookup
description: Look up weather forecasts
license: MIT
requires:
network:
- https://api.openweathermap.org
secrets:
- name: OPENWEATHER_API_KEY
provider: OpenWeatherMap
signup_url: https://home.openweathermap.org/api_keys
instructions: >-
Sign up at openweathermap.org and generate a free
API key from your account dashboard.
---
The platform provides API keys for select services. Skills
use them identically to user-provided secrets (get_secret()),
but users don't need to supply their own key. Declare in
requires.secrets; network access is automatic (no
requires.network needed for built-in key domains).
GOOGLE_API_KEY — Gemini models via Google AI.
requires:
secrets:
- name: GOOGLE_API_KEY
provider: google
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
import httpx
key = get_secret("GOOGLE_API_KEY")
resp = httpx.post(
"https://generativelanguage.googleapis.com"
"/v1beta/models/gemini-2.0-flash:generateContent",
headers={"x-goog-api-key": key},
json={"contents": [{"parts": [{"text": "Hi"}]}]},
)
resp.raise_for_status()
output_json(resp.json())
ELEVENLABS_API_KEY — text-to-speech via ElevenLabs.
Restrictions enforced by the proxy:
/v1/text-to-speech/** and /v1/models only
(everything else 403)eleven_flash_v2_5, eleven_multilingual_v2,
eleven_v3 (other model_ids 403)/v1/voices is blocked.
Default "Rachel": 21m00Tcm4TlvDq8ikWAMrequires:
secrets:
- name: ELEVENLABS_API_KEY
provider: elevenlabs
import sys
import httpx
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
from auriga.ion.cards import build_file_download_card
key = get_secret("ELEVENLABS_API_KEY")
text = sys.argv[1] if len(sys.argv) > 1 else "Hello"
resp = httpx.post(
"https://api.elevenlabs.io/v1/text-to-speech"
"/21m00Tcm4TlvDq8ikWAM",
headers={"xi-api-key": key},
json={"text": text, "model_id": "eleven_flash_v2_5"},
)
resp.raise_for_status()
out = "/chat/files/speech.mp3"
with open(out, "wb") as f:
f.write(resp.content)
card = build_file_download_card(
card_id="tts-audio",
title="Generated Audio",
files=[{"name": "speech.mp3", "vfs_path": out,
"mime_type": "audio/mpeg"}],
)
output_json({"text": "Audio ready.", "cards": [card]})
Pick the right card:
quick_reply: preferred for A/B/C choice. Tappable prompt
continuations that collapse to plain text on tap. Works
uniformly across web, WhatsApp, and voice. Use
build_quick_reply(text=..., suggestions=["Yes", "No"]); the
selected label arrives as the next user turn text.markdown: preferred for structured prose (itineraries,
specs, recipes, summaries). One-way content with no tappable
affordances. Renders as a markdown bubble on web, WhatsApp
dialect inline on Twilio (headings -> bold, bullets -> - ,
links -> label (url)), and reference material on voice
(model summarises rather than reads verbatim). Use
build_markdown_card(markdown=..., title=...). Write
CommonMark with WhatsApp as the floor: no tables, no nested
inline links, <=16 KiB body.action: confirmation / choices with structured buttons.
Only reach for this when you need card_id/button_id
identity (stale-turn rejection, analytics) or args payloads.
Degrades to quick-reply on WhatsApp and is invisible on voice.progress: multi-step form status.file_download: downloadable or previewable media files.social_posts: feed items.html: interactive widget / chart / custom form. Read
/sys/docs/ion/cards for build_html_card() and
/sys/docs/html-card for the JS SDK surface
(window.auriga.ion.files.*, skill.invoke, http.fetch)
BEFORE emitting. Payloads must use rl-* classes and
var(--rl-*) tokens — do NOT hardcode colors or fonts.
For forms/selections, use
skill.invoke(button_id, args) and read the payload next
turn via read_card_action()["args"] — don't stringify
values into prompt text.PERPLEXITY_API_KEY — web research via Perplexity Sonar.
Restrictions enforced by the proxy:
/chat/completions only (everything else 403)sonar, sonar-pro, sonar-reasoning-pro
(sonar-deep-research and unknown models 403)requires:
secrets:
- name: PERPLEXITY_API_KEY
provider: perplexity
import sys
import httpx
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
key = get_secret("PERPLEXITY_API_KEY")
query = sys.argv[1] if len(sys.argv) > 1 else "Hello"
resp = httpx.post(
"https://api.perplexity.ai/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={
"model": "sonar",
"messages": [{"role": "user", "content": query}],
},
)
resp.raise_for_status()
output_json(resp.json())
X_API_BEARER_TOKEN — read-only X.com (Twitter) API v2.
Restrictions enforced by the proxy:
/2/tweets/search/recent,
/2/tweets/search/all, /2/tweets/counts/**,
/2/tweets, /2/users/**, /2/users,
/2/lists/**, /2/trends/**, /2/spaces/**,
/2/communities/**/2/tweets/search/stream,
/2/tweets/sample return 403)requires:
secrets:
- name: X_API_BEARER_TOKEN
provider: x
import sys
import httpx
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
key = get_secret("X_API_BEARER_TOKEN")
query = sys.argv[1] if len(sys.argv) > 1 else "python"
resp = httpx.get(
"https://api.x.com/2/tweets/search/recent",
headers={"Authorization": f"Bearer {key}"},
params={
"query": query,
"max_results": 10,
"tweet.fields": "author_id,created_at,public_metrics",
},
)
resp.raise_for_status()
output_json(resp.json())
YOUTUBE_API_KEY — YouTube Data API v3 search and metadata.
Restrictions enforced by the proxy:
/youtube/v3/** only (everything else 403)?key= query param (do NOT use a header — the proxy keeps
the key in the query string for this API)requires:
secrets:
- name: YOUTUBE_API_KEY
provider: google
import sys
import httpx
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
key = get_secret("YOUTUBE_API_KEY")
query = sys.argv[1] if len(sys.argv) > 1 else "python"
resp = httpx.get(
"https://www.googleapis.com/youtube/v3/search",
params={
"part": "snippet",
"type": "video",
"maxResults": 5,
"q": query,
"key": key,
},
)
resp.raise_for_status()
output_json(resp.json())
GOOGLE_MAPS_API_KEY — Google Maps Platform + Places API.
Two domains, each with different auth:
maps.googleapis.com — Geocoding, Directions, Distance Matrix,
Elevation, Time Zone, Maps Static. Auth via ?key= query param.
Allowed endpoints: /maps/api/geocode/**, /maps/api/directions/**,
/maps/api/distancematrix/**, /maps/api/elevation/**,
/maps/api/timezone/**, /maps/api/staticmap.
places.googleapis.com — Places API (New). Auth via
X-Goog-Api-Key header. Allowed endpoints:
/v1/places:searchNearby, /v1/places:searchText, /v1/places/**.
requires:
secrets:
- name: GOOGLE_MAPS_API_KEY
provider: google
import httpx
from auriga.ion.vfs import get_secret
from auriga.ion.output import output_json
key = get_secret("GOOGLE_MAPS_API_KEY")
# Maps API: key goes in ?key= query param
resp = httpx.get(
"https://maps.googleapis.com/maps/api/geocode/json",
params={"address": "Sydney Opera House", "key": key},
)
resp.raise_for_status()
result = resp.json()["results"][0]
lat = result["geometry"]["location"]["lat"]
lng = result["geometry"]["location"]["lng"]
# Places API (New): key goes in X-Goog-Api-Key header
resp2 = httpx.post(
"https://places.googleapis.com/v1/places:searchText",
headers={
"X-Goog-Api-Key": key,
"X-Goog-FieldMask": "places.displayName,places.formattedAddress",
},
json={"textQuery": "coffee near Sydney Opera House", "maxResultCount": 5},
)
resp2.raise_for_status()
output_json(resp2.json())
For static map images, write PNG bytes to /chat/files/<name>.png and
emit with build_file_download_card (see auriga.ion.cards.builders).
---
schema_version: "1.0.0"
name: my-calendar
description: Read calendar events
requires:
oauth_scopes:
- https://www.googleapis.com/auth/calendar.events.readonly
---
Use requires.vfs only for non-skill paths (like
/sys/channels/gchat/dm or /sys/channels/mail/send). A
skill's own file tree is always
accessible. To read another skill's files, use
requires.vfs with the appropriate path.
Persistent data path: /skill/data/{filename} inside scripts
(via bind mount). In SKILL.md body, use
read_file("data/{filename}", skill_name="{SKILL_NAME}").
requires.vfs needed)/data/foo — will ENOENTReading from SKILL.md: use read_file (a runtime tool).
Writing from SKILL.md: there is NO write_file tool. All
writes go through a script via exec_file. Create the script
as part of the build. The script uses standard Python file I/O
on /skill/data/...:
# scripts/save_data.py
import json, sys
from auriga.ion.output import output_json
DATA = "/skill/data/items.json"
data = json.loads(sys.argv[1])
with open(DATA, "w") as f:
json.dump(data, f)
output_json({"saved": True})
SKILL.md invocation:
exec_file("scripts/save_data.py", ["<json>"],
skill_name="{SKILL_NAME}",
user_display_hint="Saving...")
Persist state that makes the skill better over time:
Structure: single JSON file at /skill/data/state.json for
simple skills. Split into multiple files when domains are
independent (e.g. preferences.json + history.json).
Keep files small. Summarize rather than append raw logs. Prune old entries when a collection grows past ~100 items.
On first run, state files won't exist — always handle the
empty/missing case gracefully (default to {} or []).
Scripts live at /skills/user/{name}/draft/scripts/*.py (written
via ion_write). In the SKILL.md body, use relative paths
with skill_name="{SKILL_NAME}" — the runtime resolves the
version automatically.
SKILL.md examples MUST use relative paths and include
skill_name="{SKILL_NAME}". The {SKILL_NAME} placeholder
is substituted with the actual skill slug at load time:
exec_file("scripts/run.py", ["arg"], skill_name="{SKILL_NAME}",
user_display_hint="Running task...")
read_file("data/items.json", skill_name="{SKILL_NAME}")
Always pass user_display_hint to show a status message in
the chat UI while a script runs:
exec_file("scripts/tts.py", ["chunk-1"], skill_name="{SKILL_NAME}",
user_display_hint="Generating audio chunk 1/5...")
Use hints for long-running or multi-step scripts so users see progress instead of a blank spinner.
Scripts access their own data via /skill/data/... (a bind
mount to the skill's VFS subtree). The env var
AURIGA_SKILL_SLUG contains the skill name if needed
programmatically.
Arguments arrive via sys.argv (each exec_file args array
element becomes a separate argv entry). Use the Ion SDK for
output:
#!/usr/bin/env python3
import sys
from auriga.ion.output import output_json, output_error
def main():
if len(sys.argv) < 2:
output_error("Missing required argument")
output_json({"result": "success"})
if __name__ == "__main__":
main()
Read via get_secret(), never os.environ or hardcoded keys:
from auriga.ion.vfs import get_secret
api_key = get_secret("OPENWEATHER_API_KEY")
Use service() for raw Google API access. Missing OAuth scopes
are handled automatically by the runtime (an authorization card
is shown to the user). No special wrapper needed.
#!/usr/bin/env python3
from auriga.ion.google import service
from auriga.ion.output import output_json
def main():
# service() returns a raw google-api-python-client Resource.
# access="read" requests read-only scopes; "write" for read+write.
cal = service("calendar", "v3", access="read")
events = cal.events().list(
calendarId="primary", maxResults=10,
singleEvents=True, orderBy="startTime",
).execute()
output_json(events.get("items", []))
if __name__ == "__main__":
main()
service() returns a google-api-python-client Resource —
methods map 1:1 to the REST API. Supported services:
calendar v3 — developers.google.com/calendar/api/v3/referencegmail v1 — developers.google.com/gmail/api/reference/restdrive v3 — developers.google.com/drive/api/reference/rest/v3sheets v4 — developers.google.com/sheets/api/reference/restdocs v1 — developers.google.com/docs/api/reference/restslides v1 — developers.google.com/slides/api/reference/restEach service also has a convenience module with helpers. IMPORTANT: Before writing scripts that use these convenience functions, you MUST read the API reference to check signatures:
ion_read("/sys/docs/ion/google/calendar")
Replace sheets with the service you need (calendar, gmail,
drive, docs, slides). Do NOT guess at function signatures —
many have opinionated defaults.
Each module also exposes api(access=...) for raw Google API
access (same as service()).
Use httpx (available in sandbox). Prefer direct REST calls
over vendor SDKs when feasible — it's simpler and avoids an
extra venv build on first run. For cases where a vendor SDK
is genuinely easier, use PEP 723 (below) to declare it.
Scripts can declare their own Python package dependencies inline using PEP 723 metadata. Use this when your script needs a package that isn't in the base sandbox venv (httpx, the auriga SDK, and the standard library are always available without declaring anything).
Add a # /// script comment block at the top of the file:
# /// script
# dependencies = ["cowsay"]
# ///
import cowsay
from auriga.ion.output import output_json
msg = cowsay.get_output_string("cow", "Hello!")
output_json({"message": msg})
You can pin versions and declare multiple packages:
# /// script
# dependencies = ["beautifulsoup4>=4.12", "lxml"]
# ///
How it works: On first invocation, the runtime builds a
dedicated venv for the declared dependencies (cached by
content hash). Subsequent runs with the same dependency set
reuse the cached venv instantly. The base auriga SDK
(auriga.ion.*) remains importable alongside the extra
packages.
Limitations:
# /// script block must appear before any codeauriga.ion.output — output_json, output_errorauriga.ion.vfs — VFS helpers, get_secretauriga.ion.cards — build_file_download_card,
build_html_cardauriga.ion.google — service()auriga.ion.google.{calendar,gmail,sheets,drive,docs,slides}Read API docs: ion_read("/sys/docs/ion/{module}") or
ion_read("/sys/docs/ion/google/{service}"). Always check
signatures before using convenience functions.
Scripts write output files to /chat/files/ and present
them as download cards via build_file_download_card. The
runtime normalises sandbox paths to absolute Ion URLs before
sending to clients.
The web client renders inline previews for media files
based on mime_type:
Always set mime_type on media files so the client renders
the correct preview. Non-media files show a download link.
This is the standard way to emit any generated media
(images, audio, video) from a skill — do NOT base64-encode
media into the response text.
#!/usr/bin/env python3
import sys
from auriga.ion.output import output_json
from auriga.ion.cards import build_file_download_card
input_path = sys.argv[1]
with open(input_path) as f:
content = f.read()
# ... process content ...
output_path = "/chat/files/report.html"
with open(output_path, "w") as f:
f.write(html_content)
card = build_file_download_card(
card_id="download-report",
title="Your Report",
files=[{
"name": "report.html",
"vfs_path": output_path,
"mime_type": "text/html",
}],
)
output_json({"text": "Report ready.", "cards": [card]})
SKILL.md invocation:
exec_file("scripts/convert.py",
["/chat/files/uploads/input.md"],
skill_name="{SKILL_NAME}",
user_display_hint="Converting...")
vfs_path must start with /chat/files/. Multiple files
per card are supported. mime_type is inferred from the
extension if omitted. size (bytes) is optional.
Files in /chat/files/ persist for the entire conversation.
When a skill produces a file (image, audio, document) that the
user may want to refine in subsequent turns, the script should
accept an optional --input argument pointing to a previous
output file.
Pattern:
--input PATH (optional). When provided,
the script reads the file and sends it alongside the new
prompt to the API for editing/refinement.--input /chat/files/<previous_output> to the script.--input, the script generates from scratch.Example (image generation with Gemini):
parser.add_argument("--input", default=None,
help="Path to existing image to refine")
args = parser.parse_args()
parts = [{"text": full_prompt}]
if args.input:
image_bytes = Path(args.input).read_bytes()
b64 = base64.b64encode(image_bytes).decode()
parts.insert(0, {
"inlineData": {"mimeType": "image/png", "data": b64}
})
SKILL.md refinement instructions:
## Refinement
When the user asks to modify a previous output ("add X",
"change Y", "make it more Z"), pass the existing file:
exec_file("scripts/generate.py",
["add a party hat",
"--input", "/chat/files/output.png"],
skill_name="{SKILL_NAME}",
user_display_hint="Refining image...")
Do NOT re-describe the entire scene — only describe the
desired change.
This pattern works for any generative API that accepts input files (image editing, audio remixing, document revision).
Do NOT rely on baked-in knowledge of APIs — it may be stale.
Before writing a skill that calls an external API, use
web_search to find current docs: base URL, auth method,
endpoints, and response format.
---
schema_version: "1.0.0"
name: stock-price
description: Look up current stock prices
license: MIT
requires:
network:
- https://query1.finance.yahoo.com
---
You are a stock price assistant.
## Available tools
- `scripts/lookup.py <ticker>`
Example: `exec_file("scripts/lookup.py", ["AAPL"], skill_name="{SKILL_NAME}", user_display_hint="Looking up AAPL...")`
#!/usr/bin/env python3
import sys
import httpx
from auriga.ion.output import output_json, output_error
def main():
if len(sys.argv) < 2:
output_error("Usage: lookup.py <ticker>")
ticker = sys.argv[1].upper()
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}"
resp = httpx.get(url, params={"interval": "1d", "range": "1d"})
resp.raise_for_status()
meta = resp.json()["chart"]["result"][0]["meta"]
output_json({"ticker": ticker, "price": meta["regularMarketPrice"],
"currency": meta["currency"]})
if __name__ == "__main__":
main()
Most skills follow this pattern — load persistent state, act, save updates.
---
schema_version: "1.0.0"
name: todo
description: Manage a personal todo list
---
You manage the user's todo list.
## Tools
- `exec_file("scripts/todo.py", ["list"], skill_name="{SKILL_NAME}", user_display_hint="Loading todos...")`
- `exec_file("scripts/todo.py", ["add", "Buy groceries"], skill_name="{SKILL_NAME}", user_display_hint="Adding todo...")`
- `exec_file("scripts/todo.py", ["done", "0"], skill_name="{SKILL_NAME}", user_display_hint="Marking done...")`
- `exec_file("scripts/todo.py", ["remove", "0"], skill_name="{SKILL_NAME}", user_display_hint="Removing todo...")`
#!/usr/bin/env python3
import json, sys
from auriga.ion.output import output_json, output_error
from auriga.ion.vfs import read_vfs_file, write_vfs_file
DATA = "/skill/data/items.json"
def load():
raw = read_vfs_file(DATA)
return json.loads(raw) if raw else []
def save(items):
write_vfs_file(DATA, json.dumps(items))
def main():
if len(sys.argv) < 2:
output_error("Usage: todo.py <list|add|done|remove> [args]")
return
cmd, items = sys.argv[1], load()
if cmd == "list":
output_json({"items": items})
elif cmd == "add":
items.append({"text": " ".join(sys.argv[2:]), "done": False})
save(items)
output_json({"added": items[-1]["text"], "total": len(items)})
elif cmd == "done":
items[int(sys.argv[2])]["done"] = True
save(items)
output_json({"marked_done": items[int(sys.argv[2])]["text"]})
elif cmd == "remove":
removed = items.pop(int(sys.argv[2]))
save(items)
output_json({"removed": removed["text"], "total": len(items)})
if __name__ == "__main__":
main()
Test with run_skill. Check for errors, missing packages,
missing OAuth scopes (look for auth_url in response —
present it to the user to authorize), and correct output.
run_skill("my-skill", "test prompt")
run_skill("gmail-send", "send this",
skill_arguments={"to": "[email protected]",
"subject": "Notes",
"body": "Here are the notes"})
Once tests pass, publish immediately:
publish_skill(skill_name)
Never use environment variables for user context. Always use
the auriga.ion SDK functions below.
from auriga.ion.vfs import get_user_timezone, get_user_email
tz = get_user_timezone() # IANA timezone, e.g. "America/New_York"
email = get_user_email() # user's email address
If get_user_timezone() returns empty, ask the user for their
timezone. Skills that deal with time or scheduling must use the
user's actual timezone, not UTC.
Skills use portable paths ({SKILL_NAME} placeholder and
relative paths), so copying requires no path fixup:
create_draft("new-name")ion_list("/skills/user/source/latest/") to discover files/skills/user/new-name/draft/ — update
only the name field in SKILL.md frontmatterAutomations are the unified trigger mechanism for skills. One automation can combine any of three trigger types:
cadence (ISO 8601 duration, e.g.
P1D daily, P1W weekly; minimum PT1H).initial_fire_at (ISO 8601 datetime)
without a cadence. The automation fires once then goes idle.webhook_enabled: true. External systems
POST to the per-automation webhook URL to trigger a run.Skills can create and manage their own automations from scripts
via auriga.ion.automations. Declare the capability by
requesting VFS access to the automations subtree:
requires:
vfs:
- path: /sys/automations/*
access: write
Without this requires.vfs entry /sys/automations/ is not
mounted into the sandbox and SDK calls raise FileNotFoundError.
SDK (read ion_read("/sys/docs/ion/automations") for full
signatures):
create_automation(name=..., cadence=..., ...) — allocate,
configure, enable; returns the final name. The skill is implicit
(the running script); never pass skill=.schedule_in(timedelta(...), prompt=..., ...) — one-off relative
to now; no cadence minimum applies.ensure_webhook(name=..., prompt=...) — idempotent single
webhook per skill; returns the webhook URL directly.list_automations() — names only (excludes clone)get_automation(name) — returns an AutomationStatus
model (attribute access: s.enabled, s.cadence, ...)update_automation(name, AutomationUpdate(cadence=..., enabled=True))
— typed partial update; only set fields are writtenenable_automation(name) / disable_automation(name)delete_automation(name)Capped at 20 automations per user; minimum cadence PT1H.
Idempotent setup pattern: check list_automations() before
creating so first-run setup doesn't duplicate on rerun.
Publish the skill before scheduling — automations run against published versions, not drafts.
Skills can email the running user from agent.readyloop.ai with
no OAuth flow. Self-only: the recipient is always the current
user. Declare the capability:
requires:
vfs:
- path: /sys/channels/mail/send
access: write
SDK (read ion_read("/sys/docs/ion/mail") for full signature):
from auriga.ion.mail import send_mailsend_mail(subject=..., body=..., html=None) — returns the
Resend message id; raises MailSendError on failure.Use for digests, reminders, and scheduled briefings where Gmail
OAuth is unavailable or overkill. Pair with
/sys/automations/* for scheduled sends (see the
send-mail-automation system skill for a worked example).
An app is a web SPA backed by a skill-agent, served at
{name}.app.readyloop-staging.dev.
Apps use the platform's web client (clients/web) as their frontend. No custom React code, no Vite build, no npm dependencies. The app's display name, tagline, and theme are configured via the API — the platform UI adapts automatically.
The backing skill-agent must be published first. Build and publish it using the normal skill workflow above.
source_agent)create_app(app_name, display_name, source_agent, description, app_tagline) — app is immediately live at
https://{app_name}.app.readyloop-staging.dev/list_apps() — enumerate apps owned by the userget_app(app_name) — full detail for one appcreate_app(...) — create (also upserts for backcompat)update_app(app_name, display_name=..., app_tagline=..., custom_css_vars=..., source_agent=..., enabled=...) —
partial PATCH. Only fields you pass are changed. Use this
for renames/rebrands of visible text; do NOT call
create_app to edit.rename_app(app_name, new_app_name) — change the
subdomain. DB-only, no file copies. Version history is
preserved.delete_app(app_name) — destructive. Confirm with the
user first.The sign-in page heading, chat header, and tagline come
from the app row (display_name, app_tagline), NOT
from the backing skill's SKILL.md. Editing SKILL.md alone
will NOT change anything the end user sees on
{slug}.app.readyloop-staging.dev.
When the user asks to rename, rebrand, or change branding on an app:
list_apps() to find the backing app(s) for the skillupdate_app(app_name, display_name=..., app_tagline=...)
to change visible text (subdomain stays the same)rename_app(app_name, new_app_name) to also change the
subdomain (old one 404s; Phase 2 will add redirects)custom_css_vars)Do NOT write index.html, package.json, vite.config.ts, or any frontend code — the platform handles everything.
Pass custom_css_vars to override the default CSS variables.
These are injected into :root at runtime.
Overridable variables (grouped by function):
Backgrounds: --rl-bg, --rl-bg-secondary, --rl-bg-muted
Foregrounds: --rl-fg, --rl-fg-muted, --rl-fg-subtle
Primary action: --rl-primary, --rl-primary-fg
Accent: --rl-accent, --rl-accent-fg
Borders: --rl-border, --rl-border-strong
Danger: --rl-danger
Geometry (--rl-radius, --rl-radius-lg, --rl-shadow-card)
is platform-pinned and cannot be overridden.
Contrast rules (WCAG AA):
--rl-fg must contrast with --rl-bg (4.5:1 min)--rl-primary-fg must contrast with --rl-primary--rl-accent-fg must contrast with --rl-accent--rl-fg-muted must be readable against --rl-bg (3:1)--rl-border must be visible against --rl-bgDark theme example:
create_app(
app_name="my-app",
display_name="My App",
source_agent="my-skill-agent",
custom_css_vars={
"--rl-bg": "#0f172a",
"--rl-bg-secondary": "#1e293b",
"--rl-fg": "#e2e8f0",
"--rl-fg-muted": "#94a3b8",
"--rl-primary": "#22d3ee",
"--rl-primary-fg": "#0f172a",
"--rl-border": "#334155",
},
)
Light theme example:
create_app(
app_name="my-app",
display_name="My App",
source_agent="my-skill-agent",
custom_css_vars={
"--rl-bg": "#fef9ef",
"--rl-bg-secondary": "#fdf3dc",
"--rl-fg": "#3d2e1a",
"--rl-fg-muted": "#7a6a55",
"--rl-primary": "#d4a017",
"--rl-primary-fg": "#ffffff",
"--rl-border": "#e8d9b8",
},
)
When creating a new skill, ask the user what license they want. NEVER generate license text from memory — always use canonical texts from sysfs.
For private/closed skills (most common):
ion_read("/sys/licenses/Proprietary")[year] with current year and [holder] with
user's name (from ion_read("/sys/user/display_name"))/skills/user/{name}/draft/LICENSElicense: "Proprietary" in SKILL.md frontmatterFor open-source skills:
ion_read("/sys/licenses/MIT")
(replace MIT with the chosen SPDX identifier)/skills/user/{name}/draft/LICENSElicense: "MIT" in SKILL.md frontmatterAvailable licenses: ion_read("/sys/licenses/") to list all.
For "Custom", ask the user to provide their own license text
and write it to the LICENSE file.
Publishing validates that the license field and LICENSE file
are consistent. For SPDX IDs, the LICENSE file must match the
canonical text. Write LICENSE as part of the initial draft, not
after publish_skill — publishing with the field set but no
file emits a warning and closes the draft.
ion_read("/sys/docs/guide") — Ion SDK overviewion_read("/sys/docs/automations") — scheduling skillsion_read("/sys/docs/ion/automations") — automation SDKGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub ready-loop/auriga-plugin --plugin auriga