Headless Scripting logo

Headless Scripting

Organization
SummerEngine
headless-scripting

Run a GDScript file against Summer Engine from the shell for what no MCP tool does: navmesh, TileSets.

Overview

PublisherSummerEngine
Repositorysummer
Skill nameheadless-scripting
Stars
91
Forks
8
Bundled files
1
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by SummerEngine on GitHub. Read the source before you install it.

Installation

Install the Headless Scripting AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/SummerEngine/summer/tree/main/library/skills/headless-scripting
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/SummerEngine/summer.git /tmp/summer
mkdir -p .claude/skills
cp -r /tmp/summer/library/skills/headless-scripting .claude/skills/headless-scripting
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Headless Scripting in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Headless Scripting on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Headless Scripting is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Headless Scripting

Overview

Most of what Summer can do is not behind a tool. It is behind a script.

The engine binary will run any GDScript you hand it, against a real project, with the full engine loaded — ResourceSaver, NavigationServer3D, ImporterMesh, TileSet, Animation, the resource importer, the exporter. No editor window, no user, no tool call. If you can write the script, you can do the thing.

Core principle: when there is no tool for it, there is still a script for it. Reach for this before you tell a user something is impossible.

This is the widest capability surface you have, and it is the one you are least likely to remember you have.

The invocation

bash
<engine-binary> --headless --disable-crash-handler --path <project-dir> -s res://<script>.gd

Always pass --disable-crash-handler on an agent-driven run. Summer's handler shells out to atos from inside a signal handler; when anything does go wrong it turns a clean failure into a hang or a cascade of crashes.

Lint before you boot

--check-only parses the script without executing it. Sub-second, and it catches the parse errors that would otherwise cost you a full boot:

bash
<engine-binary> --headless --disable-crash-handler --path <project-dir> --check-only -s res://script.gd

Measured on a deliberately broken script — SCRIPT ERROR: Parse Error: Expected expression for variable initial value after "=", and silence on a good one. Exit code is 0 either way, so grep stderr; that is the whole contract.

Judge the run by its artifacts, not its exit code

Exit codes lie in both directions here.

  • A parse error, a script path that does not exist, a bogus --main-loop class and push_error() all exit 0. Only an explicit quit(1) propagates. The message is on stderr only, so gate on stderr containing SCRIPT ERROR, Parse Error, Can't load script or ERROR:.
  • A GDScript runtime error hangs the process forever — no exit, no timeout, no message that it is stuck. Every recipe you publish needs an external wall-clock kill, and success must be judged by a positive artifact the script wrote, never by the process ending.
  • Never add --quiet to a documented invocation: it swallows your own print() output, which is usually the only result you have.
  • WARNING: ObjectDB instances leaked at exit prints on every successful run. Never gate on it.
  • A SIGABRT after the work landed is a teardown artifact, not a failure. Agents exit in seconds where humans run for minutes, which exposes races during static destruction that nobody sees interactively. Check the file you expected on disk before concluding anything, and never blind-retry a crash — that is how one crash became three.

Two invocations to never issue

  • --write-movie under --headless is a deterministic crash, not a graceful failure. MovieWriter::add_frame() dereferences a null image from the dummy renderer on frame one. Headless has no pixels, so there is no safe form of this. Frame capture needs the windowed verify instance below.
  • --quit-after 1 hits the fast-exit path and aborts during static destruction. Use --headless --quit-after 60 if you need a bounded editor run — with --headless, never without.

Never put an extra window on the user's screen from a shell

The one exception is the verify instance, and it is safe precisely because it is not visible: --summer-verify opens a real rendering window that is hidden, NO_FOCUS, and positioned at (-32000,-32000). That is why it can produce real pixels without ever appearing. Never reproduce the "I need a renderer" reasoning by launching a plain windowed editor — use --summer-verify.

This rule is about extra engine processes you start from a shell. Opening the Summer editor and playing the game in a window through the Summer tools is allowed and expected: summer_play, then summer_screenshot or summer_frame_shot to see the result. Close what you opened when you are done; one editor at a time.

Finding the binary — do not guess

Call summer_get_project_context and read engineBinaryPath. That is the running engine's own OS.get_executable_path(), already resolved through the macOS .app bundle to the real inner executable.

If you are inside a script that is already running in the engine, it is just OS.get_executable_path().

If neither is available (an older engine build that predates engineBinaryPath, or no editor running), fall back to the install location for the platform:

PlatformPath
macOS/Applications/Summer.app/Contents/MacOS/Summer
Windows%LOCALAPPDATA%\Summer\current\Summer.exe
Linux~/.summer/engine/summer-linux-x86_64, or $SUMMER_ENGINE_BINARY (see running-in-the-cloud)

The script must extend SceneTree or MainLoop

-s does not load an arbitrary script. The engine instantiates it as the process's main loop, and rejects anything that is not one.

gdscript
extends SceneTree

func _initialize():
    # your work here
    quit()          # or the process runs forever

_initialize() runs before the first frame. If you need frames to have elapsed — physics settled, nodes _ready-ed — await process_frame inside _initialize(), or use _process(delta) and quit() when done.

Always call quit(). There is no other exit.

The trap: a script extending Node (the reflex, since almost all GDScript does) is a wedge, not an error. The engine's rejection path calls OS.alert(), and on macOS that is an NSAlert runModal with no headless guard. The process blocks forever on a modal dialog that has no window to appear in. You get no output at all and a hung process that must be killed. Measured: five orphaned processes accumulated in one session before the cause was found.

If a headless run produces zero output and never returns, check the first line of your script before you check anything else.

What this unlocks

Every one of these was run against the shipped binary. The result column is measured output, not expectation.

TaskResult
Collision shape from a meshcreate_trimesh_shape() → 12 faces; create_convex_shape() → 8 points
Navmesh bakeNavigationServer3D.bake_from_source_geometry_data() → 2 polys / 4 verts, saved to .tres
LOD generationImporterMesh instantiable, generate_lods() callable
Animation authoringvalue + bezier + method tracks saved and reloaded with all 3 intact
TileSet authoringatlas source + per-tile collision polygon, saved to .tres
Asset re-import--import produced the .import sidecar and the .ctex
Full exporthand-written export_presets.cfg + --export-release → a 144 MB runnable .app

Themes, shaders, audio bus layouts, input maps and project settings are the same story: plain resources, authored and saved from a script.

Bake a navmesh

gdscript
extends SceneTree

func _initialize():
    var nav := NavigationMesh.new()
    nav.cell_size = 0.25
    nav.agent_radius = 0.5

    var geo := NavigationMeshSourceGeometryData3D.new()
    var ground := PlaneMesh.new()
    ground.size = Vector2(10, 10)
    geo.add_mesh(ground, Transform3D.IDENTITY)

    NavigationServer3D.bake_from_source_geometry_data(nav, geo)
    print("polys=", nav.get_polygon_count(), " verts=", nav.vertices.size())
    ResourceSaver.save(nav, "res://levels/level1_nav.tres")
    quit()

ResourceSaver.save() returns 0 (OK) on success. Check it — a silent non-zero is how you ship a level with no navmesh.

Prefer parsing collision shapes over visual meshes as source geometry. add_mesh() on a visual mesh pulls geometry back off the GPU and warns loudly about it.

Generate a collision shape

gdscript
var mesh: Mesh = load("res://models/rock.glb").instantiate().get_child(0).mesh
ResourceSaver.save(mesh.create_trimesh_shape(), "res://models/rock_col.tres")   # static
ResourceSaver.save(mesh.create_convex_shape(),  "res://models/rock_cvx.tres")   # dynamic

Trimesh for static geometry, convex for anything that moves — a RigidBody3D with a concave shape is a physics bug, not a modelling choice.

Author an Animation

gdscript
var anim := Animation.new()
anim.length = 2.0

var t := anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(t, "Player:position")
anim.track_insert_key(t, 0.0, Vector3.ZERO)
anim.track_insert_key(t, 2.0, Vector3(0, 5, 0))

var b := anim.add_track(Animation.TYPE_BEZIER)
anim.track_set_path(b, "Player:modulate:a")
anim.bezier_track_insert_key(b, 0.0, 1.0, Vector2(-0.2, 0), Vector2(0.2, 0))

var m := anim.add_track(Animation.TYPE_METHOD)
anim.track_set_path(m, "Player")
anim.track_insert_key(m, 1.0, {"method": "on_landed", "args": []})

ResourceSaver.save(anim, "res://animations/rise.tres")

Track paths are NodePath:property relative to the AnimationPlayer's root_node. Getting that path wrong is the usual reason an animation loads fine and animates nothing.

Build a TileSet with collision

gdscript
var ts := TileSet.new()
ts.tile_size = Vector2i(16, 16)
ts.add_physics_layer()

var atlas := TileSetAtlasSource.new()
atlas.texture = load("res://art/tiles.png")
atlas.texture_region_size = Vector2i(16, 16)
atlas.create_tile(Vector2i(0, 0))
ts.add_source(atlas)

var tile := atlas.get_tile_data(Vector2i(0, 0), 0)
tile.add_collision_polygon(0)
tile.set_collision_polygon_points(0, 0, PackedVector2Array([
    Vector2(-8, -8), Vector2(8, -8), Vector2(8, 8), Vector2(-8, 8)
]))

ResourceSaver.save(ts, "res://levels/tiles.tres")

add_physics_layer() returns void, not the layer index. Layers are indexed in creation order from 0.

Build a scene from script

PackedScene.pack() + ResourceSaver.save() is how you write a .tscn a script built. It has one trap that costs an entire run:

Measured — a root with one owned child, one unowned sibling, and an unowned grandchild under the owned one:

PACK err=0
SAVE err=0
RELOADED children=[&"Owned"]        <- both unowned nodes gone, no error anywhere

Note the grandchild vanished too: being a descendant of an owned node is not enough. Every node you want persisted needs .owner set to the scene root. The root itself does not need one.

gdscript
var root := Node2D.new()

var layer := TileMapLayer.new()
layer.tile_set = ts
root.add_child(layer)
layer.owner = root              # omit this and the layer vanishes, silently

var ps := PackedScene.new()
ps.pack(root)
ResourceSaver.save(ps, "res://levels/level1.tscn")

This is also the answer for tilemaps specifically. A TileMapLayer's cells serialise into tile_map_data, a PackedByteArray — so "just hand-write the .tscn" stops being true the moment there are cells in it. Use set_cell() and pack the scene; do not try to edit that field as text.

Re-import assets you wrote to disk

This is the single most common way an agent breaks a project. A .png written directly to disk is not a resource yet:

ERROR: No loader found for resource: res://raw.png (expected type: unknown)
   at: _load (core/io/resource_loader.cpp:358)

The file exists. FileAccess.file_exists() returns true. load() fails anyway, because there is no .import sidecar and no imported .ctex in .godot/imported/.

Prefer the routes that need no import at all

Reach for these before reaching for --import:

  1. ResourceSaver.save() to .tres / .res. No import step, no editor, no .godot/ race, and it ships correctly in an exported build. Round-trips for ImageTexture, AudioStreamWAV, AudioStreamOggVorbis, AudioStreamMP3, FontFile, ArrayMesh, Translation, AudioBusLayout. (One trap: PortableCompressedTexture2D saves with err 0 and reloads empty — use ImageTexture.)
  2. .dds and .po load raw. They are the only formats that need no .import and ship as-is. A DDS is a 128-byte header plus raw pixels, which a script can emit directly.
  3. summer_import_from_url already does its own rescan-and-settle in-process, so assets that arrive that way are registered without any of this.

Only when none of those fit — you genuinely need a .png on disk to become a texture — run the import:

bash
<engine-binary> --headless --disable-crash-handler --path <project-dir> --import

Judge it by the .import sidecar and the file in .godot/imported/, not by the exit status. If those exist the import succeeded, however the process died. Do not retry blindly. Measured 3/3 clean on 4.6.1; a wider sample put --import aborts at 2/12 against --editor's 3/50, statistically the same background boot-abort rate rather than an --import-specific crash.

Measured on 0.5.55: port 6550 -> 6551, new token, editor still alive on 6550 and now unreachable.

So on a shipped build: run --import against a throwaway copy where you can, warn the user before running it against their open project, and tell them to restart the editor afterwards if their agent connection drops.

A later engine fix stops batch-mode invocations (--import, --summer-verify, --export-*, -s, --headless, --quit-after) from publishing. On a build with that fix this warning does not apply; if you cannot tell which build is installed, follow the precautions above. Plain --headless -s never started the server on any build, so everything else in this skill is unaffected.

Two lifecycle facts that decide the shape of every headless run:

  • There is a hard work-completion floor between 3 and 5 seconds. Fresh project, 8 small PNGs, editor killed at N seconds: at 3s, 0/8 imports landed in 10/10 cycles — it boots, gets killed mid-scan, and accomplishes nothing. At 5s, 8/8 landed every cycle. Never give an import a 3-second budget.
  • --headless --editor is not a filesystem watcher. It never notices files created after boot — 6 assets added 12s in, 45s wait, 0 imported. The rescan is driven by NOTIFICATION_APPLICATION_FOCUS_IN, and headless never fires a focus event. A long-lived headless editor serves a boot-time snapshot forever.

Both ends of the lifecycle are broken, differently: short runs do no work, long ones go stale. Boot → act → exit is the only shape that works today.

Measured before and after on the same file:

FILE_EXISTS=true   IMPORT_SIDECAR=false  → ERROR: No loader found for resource
FILE_EXISTS=true   IMPORT_SIDECAR=true   → LOAD_RESULT=<CompressedTexture2D>

Run --import after any batch of raw asset files you wrote yourself — textures, audio, models. The MCP's summer_import_from_url already does its own rescan-and-settle, so assets that arrive that way are fine; assets you wrote with summer_write_file or your own shell are not.

Export a build

export_presets.cfg is plain text you can write. Then:

bash
<engine-binary> --headless --path <project-dir> --export-release "macOS" <out-dir>/Game.app

Exit code 0 and a real bundle on disk are the success condition — check both. Export templates for the engine's exact version must be installed, or the export fails.

The trap: --headless has no pixels

ERROR: Parameter "t" is null.
   at: texture_2d_get (servers/rendering/dummy/storage/texture_storage.h:106)
GET_IMAGE=<Object#null>

Any plan of the form "run the game headless and screenshot it" is dead on arrival. It does not error usefully — it hands you a null and continues.

Performance is equally meaningless there, but not in the way it first looks. Sampled every 30 frames across a 401-frame headless run of the same scene:

fps=1.0    draw_calls=0.0  objects=0.0     <- first ~1s
fps=117.0  draw_calls=0.0  objects=0.0
fps=145.0  draw_calls=0.0  objects=0.0

Two separate traps in that table:

  1. draw_calls and objects are 0.0 at every sample, forever. That is the real headless signature — there is no renderer to count. Those monitors never become meaningful.
  2. The FPS number is not 1.0, and it is not real either. It settles high (145 here) precisely because nothing is being drawn. Quoting it as the game's frame rate is worse than quoting nothing.

And a third trap that is not headless-specific: TIME_FPS reads exactly 1.0 until it has about a second of history. A probe that samples too early reports 1 fps in a windowed instance too. A leading 1.0 is warm-up, not a stall — sample for at least a second before believing any frame rate.

The answer is not headless. The engine ships a second mode: an offscreen windowed instance with a real renderer, positioned far off-screen so the user never sees it. That is what RunVerification spawns, and you can spawn it directly:

bash
<engine-binary> --path <project-dir> \
  --summer-verify <abs-path-to-probe.gd> \
  --summer-verify-out <abs-out-dir> \
  --summer-verify-max 30

Note: no --headless. That is the entire difference.

Measured in that instance, same project, same machine:

viewport_image_null : false          (1152x648, pixel(4,4) = the real rendered colour)
display_server      : macOS          (Metal 4.0 — Forward+)
draw_calls          : 1.0            (vs 0.0 headless, at every sample)
frames saved        : 00_start.jpg  01_after_walk.jpg  02_after_jump.jpg

So: scripting and authoring go through --headless. Seeing and measuring go through the verify instance. Choose on that axis and you will not get a null.

For writing probes — the SummerProbeBase API, the coroutine rules, what is and is not reproducible — see playtesting-a-feature.

What a script genuinely cannot do

Short list, and it is short on purpose. Everything not on it is worth attempting.

  • Lightmap baking — headless specifically. The binding exists on this build (Summer script-binds LightmapGI.bake(from_node, image_data_path)), but the GPU lightmapper needs a RenderingDevice, which a --headless process never has: the call returns BAKE_ERROR_NO_LIGHTMAPPER. Bake from summer_run_script in the live editor instead — a normal desktop editor works as-is; a cloud container needs the xvfb + GL renderer setup from running-in-the-cloud. Wrong reason kills the right fix: it is "no renderer in this process", not "no binding".
  • Occlusion culling bake. OccluderInstance3D.bake_single_node is not script-callable at all. Editor-button only.
  • Anything needing EditorInterface. Outside the editor, ClassDB.can_instantiate("EditorScript") is false. Trap: Engine.has_singleton("EditorInterface") returns true even in a plain headless script — it lies. Retrieving the singleton then fails with Can't retrieve singleton 'EditorInterface' outside of editor. Test with ClassDB.can_instantiate("EditorScript"), which tells the truth.

Never verify a write by reading it back from the thing you wrote

This is the single highest-yield rule in this document, because it is the failure mode with no symptom. In-process read-back returns what you set, from memory, whether or not anything reached disk or took effect. The check passes, the work is gone.

Four measured instances of the same shape:

What you didWhat reading it back saysWhat is actually true
PackedScene.pack() with unset ownerspack 0, save 0Those nodes are not in the file
ProjectSettings.set_setting() without save()the new valueNothing on disk; next boot has the old one
A setting with a feature-tag overridethe base valueThe engine is using the override
PathFollow2D.progress = 50.050.0The node never moved

Verify from a second process, or by re-reading the file from disk. Cheapest reliable form: finish the script, then run a second one-liner that load()s the artefact fresh and prints what it found — which is exactly what the measurements in this document do. ResourceSaver.save() returning 0 means "the write was attempted without error", not "the thing you meant is in there".

Working pattern

  1. Write the script with summer_write_file (or your own shell) into the project.
  2. Get the binary from summer_get_project_context → engineBinaryPath.
  3. Run it. Read stdout and stderr: your print() output is on stdout, script errors are on stderr, and a script that failed to parse still exits 0.
  4. Verify the artifact exists on disk and check the ResourceSaver.save() return value. "The script ran" is not "the resource was written".
  5. If you wrote raw asset files, run --import.
  6. Delete throwaway scripts. Do not leave probe_temp_3.gd in a user's project.

Keep scripts in the project (res:// is required for -s), but name them so their purpose is obvious and clean up after yourself.

Red Flags — STOP

Red flagReality
godot --headless ...That binary does not exist on any user machine. Use engineBinaryPath.
A -s script that extends NodeSilent hang on an invisible modal. Must be SceneTree or MainLoop.
Headless run returned nothing and never exitedYou extended the wrong class. Kill it and fix line 1.
"Run it headless and screenshot"Headless has no pixels. Use the verify instance, without --headless.
Profiling under --headlessdraw_calls/objects are 0.0 forever; FPS is inflated by drawing nothing. Use the verify instance.
Quoting a frame rate sampled in the first secondTIME_FPS reads 1.0 until it warms up, in every mode.
Wrote a .png, then load()ed itNo loader found. Save a .tres instead, or run --import.
--import against the user's open project on a shipped buildHijacks their editor binding. Warn first.
Killing an import at 3sMeasured 0/8 assets registered. The floor is ~5s.
A long-lived --headless --editor as a file watcherIt never rescans. Boot, act, exit.
Adding --quietSwallows your own print() output.
A shell script run without --headlessPops an extra window onto the user's screen unasked. To see the game, use summer_play and summer_screenshot in the open editor.
Ignoring ResourceSaver.save()'s return valueNon-zero is a silent failure that ships.
pack() without setting .owner on every nodeTwo success codes, nodes missing from the file.
Verifying a write by reading the object you just wroteIn-memory read-back passes whether or not it landed.
Script with no quit()Runs forever.
"There's no tool for that, so it can't be done"There is a script for it. Write the script.

Related skills:

  • playtesting-a-feature — the verify instance, probes, and proving gameplay behaviour.
  • export-and-ship — the full release pipeline this skill's export example is one step of.
  • verification-before-completion — checking the artifact before claiming done.

From a shell

No Summer MCP tools in this session? Run each one as a command; when summer --version is missing or below 4.0, use npx -y summer-engine@latest in place of summer. Fields are flags in kebab-case (--scene-path res://main.tscn) or one --input '<json>', and summer ops describe <op> prints the exact input. The summer-cli skill has the rest.

In this skillCommand
summer_frame_shotsummer engine frame-shot
summer_get_project_contextsummer engine get-project-context
summer_import_from_urlsummer engine import-from-url
summer_playsummer engine play
summer_run_scriptsummer engine run-script
summer_screenshotsummer engine screenshot
summer_write_filesummer engine write-file

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Headless Scripting AI skill do?

Run a GDScript file against Summer Engine from the shell for what no MCP tool does: navmesh, TileSets.

Why use Headless Scripting on TypingMind?

Because you install it once and use it with any model. Headless Scripting is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Headless Scripting in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/SummerEngine/summer/tree/main/library/skills/headless-scripting. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Headless Scripting?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Headless Scripting?

As many as you like. As long as a model supports skills, you can use Headless Scripting with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Headless Scripting AI skill free?

Yes. It is published on GitHub by SummerEngine under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇