# Customize

> Create, configure, and maintain custom agent profiles and author new skills via the `repl` tool. Use when the user wants to create an agent profile, build a custom agent, modify agent capabilities, attach or detach skills/connectors on a profile, author a skill, or inspect which connectors and tools are available. Also use whenever you need the `host.agents.*` or `host.skills.*` Python SDK.

- Skill: `jimliu/customize` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jimliu/customize`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jimliu/customize/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: Apache-2.0
- Author: Jim Liu (baoyu) (https://skillmd.com/u/jimliu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jimliu/customize

---


# Customize

Build and maintain **agent profiles** and **skills** programmatically via the
`repl` tool using `host.agents.*` and `host.skills.*`.

A **profile** is a named bundle that shapes how an agent behaves:

- **`system_prompt`** — the profile's **identity**. This is the opening of the
  agent's system prompt; it REPLACES the generic "You are Claude Science" base identity.
  Write it in second person, lead with `You are {display_name}, ...`, state what
  the agent specializes in and what it does NOT do. Everything else (tool-usage
  rules, working-style bullets, scope guardrail) is inherited automatically —
  don't restate it.
- **`display_name` / `description` / `icon_key` / `color_key`** — picker metadata.
- **`skill_names`** (optional restriction) — by default a profile sees the
  **full live skill catalog** via `search_skills` / `skill(...)`, same as the
  main agent. Pass an explicit list ONLY to deliberately restrict it; `[]` creates
  a zero-skill specialist. **Restricting skills also restricts connectors** —
  a single `unrestricted` flag governs both; passing `skill_names` flips the
  profile to curated mode and starts it with **zero** connectors (see next).
- **Connector access** — an **unrestricted** profile (the default) reaches
  **every connector** (bundled + custom + authorized directory), same as the
  main agent; use `detach_connector` to subtract specific ones. A **curated**
  profile (one created with an explicit `skill_names` list, or flipped via
  `{"unrestricted": False}`) starts with **no connectors** — reach is exactly
  what you `attach_connector`.
- **`excludedTools`** — per-tool blocklist applied *after* connectors resolve.
  Use to strip specific high-risk or irrelevant tools from an otherwise-useful
  connector. **Per-connector, not a profile field** — set via
  `attach_connector(..., include_tools_pattern=/exclude_tools_pattern=)`; the
  profile's `excludedTools` in `list()` is the read-only aggregation across all
  its attached connectors. Patterns match the connector's **bare** tool names
  (e.g. `'^list_marts$'`, as returned by `list_connectors(name)['tools']`); the
  aggregated `excludedTools` entries are stored fully-qualified as
  `mcp_<connector>_<tool>` since the list spans every attached connector.

---

## Python SDK

All calls run via the **`repl` tool** (see "Runs via the `repl` tool"
below). Return values are plain dicts/lists; errors raise `RuntimeError`
with a `host.agents.*:` / `host.skills.*:` prefix.

### `host.agents`

```python
host.agents.list()
# → [{"name", "displayName", "description", "source", "enabled",
#     "systemPrompt", "iconKey", "colorKey",
#     "skillNames": ["skill", ...], "connectors": ["name", ...],
#     "excludedTools": [...]}, ...]
#   (Return-dict keys are camelCase — wire shape, not kwarg names.)
#   `connectors` is a list of connector names (strings, same as skillNames) —
#   pass one to attach_connector/detach_connector or list_connectors(name).

host.agents.create(name, display_name, description,
                     system_prompt="", skill_names=None)
# name: 2–32 chars, UPPERCASE letters / digits / underscores only —
#       e.g. "RNASEQ_REVIEWER". Lowercase / dashes are rejected.
#       display_name is the human-friendly picker label.
# skill_names controls catalog visibility:
#   - leave it unset → the profile sees the FULL live skill catalog, same as
#     the main agent — skills published later appear automatically. It also
#     gets every connector the main agent has, resolved dynamically — new
#     connectors added later appear automatically too. This is the default;
#     don't pass a list unless you mean to restrict.
#   - pass a list (including []) → restricts `search_skills`/`skill(...)` to
#     EXACTLY those names. [] creates a zero-skill specialist.
# → the stored profile record (same shape as one list() entry)

host.agents.update(name, patch)
# patch: dict of fields to change — any of display_name, description,
#        system_prompt, skill_names, unrestricted, icon_key, color_key
#        (camelCase also OK).
# patch["skill_names"] is an EXACT REPLACE of the whole skill list and flips
# the profile to restricted mode — anything you omit is DETACHED. To add or
# remove a few skills, use attach_skill / detach_skill — they work on both
# restricted AND unrestricted profiles without changing the mode.
#
# If you do need a full-list replace, CHECK cur["unrestricted"] FIRST: on an
# unrestricted profile cur["skillNames"] is the lossy disk-cache view (not
# the full live catalog), so `cur["skillNames"] + ["x"]` would permanently
# freeze the profile to that partial list. The safe pattern:
#   cur = [a for a in host.agents.list() if a["name"] == name][0]
#   if cur["unrestricted"]:
#       host.agents.attach_skill(name, "new-skill")   # stays unrestricted
#   else:
#       host.agents.update(name,
#           {"skill_names": cur["skillNames"] + ["new-skill"]})
# (return-dict keys are camelCase — read "skillNames", write "skill_names")
# patch["unrestricted"] = True → back to the full live catalog + all
# connectors (undoes a skill_names restriction).
# → updated profile record
# (excludedTools is NOT a patch field — it's per-connector; use
#  attach_connector's include_tools_pattern/exclude_tools_pattern below.)

host.agents.switch(name)
# Ask to continue THIS conversation as `name`. Shows the user an approval
# card; on Allow, the switch takes effect on their NEXT message (the current
# turn finishes as the current profile). On decline, tell the user they can
# select the profile from the session config popover on any new conversation.
# → {"switched": True, "name", "displayName"}

host.agents.delete(name)
# → {"deleted": name}

host.agents.attach_skill(name, skill)
host.agents.detach_skill(name, skill)
# → updated profile record

host.agents.attach_connector(name, connector,
                               include_tools_pattern=None,
                               exclude_tools_pattern=None)
host.agents.detach_connector(name, connector)
# → updated profile record. Omit both patterns on a fresh attach to expose
#   every tool the connector offers; re-attaching an already-attached
#   connector without patterns preserves its existing exclusion list (pass
#   include_tools_pattern='.*' to clear it). Patterns match the connector's
#   BARE tool names (as returned by list_connectors(name)['tools'], e.g.
#   '^list_marts$'); the resulting excludedTools entries are stored
#   fully-qualified as mcp_<connector>_<tool>.

host.agents.list_connectors(connector_name=None)
# no arg → [{"name", "displayName", "source", "description",
#            "authState", "attachedAgents": [...]}, ...]
# with connector_name → single dict with an extra
#   "tools": [{"name", "description"}, ...]
```

### `host.skills`

```python
host.skills.list()
# → [{"name", "origin", "description"}, ...]
#   origin: "anthropic" (bundled, read-only — fork under a new name),
#           "organization"/"personal" (editable), "draft" (local, unpublished)

host.skills.read(name, path="SKILL.md")
# → {"name", "path", "content": "..."}

host.skills.edit(name, path, content, old_string=None)
# old_string=None → create `path` with `content` (fails if the file already
#                   exists — read it, then edit with a non-empty old_string)
# old_string=str  → str_replace the single exact match (rejected unless it
#                   matches exactly once — add surrounding context if needed)
# → {"action", "path", "draft_path", "note"}

host.skills.publish(name, overwrite=False)
# publish takes NO content args — write SKILL.md via .edit() first.
# → {"status": "published", "skill_id", "name", "note"}

host.skills.delete(name)
# draft → removes local dir; org/personal → unpublishes + removes local
# cache; anthropic bundled → protected.
# → {"deleted": name}  (plus "unpublished": True for published skills)
```

### Runs via the `repl` tool

`host.agents.*` / `host.skills.*` execute in the **control-plane
kernel** — a separate Python process from your `python` cells, reached via
the **`repl` tool** (not the `python` tool). It shares your workspace
directory (cwd) but **not** memory, so variables from `python` cells aren't
visible there and vice-versa. To hand results across, write to a file —
same pattern as Python↔R:

```python
# repl tool
import json, os
os.makedirs("handoff", exist_ok=True)
profiles = host.agents.list()
json.dump(profiles, open("handoff/agents.json", "w"))
```

```python
# python tool
import json, pandas as pd
profiles = json.load(open("handoff/agents.json"))
pd.DataFrame(profiles)[["name", "source", "enabled"]]
```

---

## Workflow: scope → draft → review → create

**Do not call `generate_plan` for profile CRUD.** This is a single
scope→draft→confirm loop; `ask_user` (step 4) is the review gate. A plan
adds a second approval that duplicates the `ask_user` confirmation and
drags in step-status bookkeeping that fights this workflow.

**User approval.** Most `host.agents.*` / `host.skills.*` calls apply
immediately — `create`/`update` (including unrestricted),
`attach_*`/`detach_*`, and `skills.publish`/`skills.edit` are pre-approved
at session start and the cell does NOT pause. An approval card (cell
pauses, resumes automatically on Allow — you don't retry) appears only
for the calls that **hand off identity or free a granted name**:

- `host.agents.switch(name)` — per-target; Allow covers this name only
- `host.agents.update(name, {"name": ...})` (rename) — per-target
- `host.agents.delete(name)` / `host.skills.delete(name)` — **one card
  per project**: the first delete shows a card; "Allow for this project"
  covers every subsequent delete in this project, including bulk
  teardown. Do NOT tell the user they'll see N cards for N deletes.

The name is read from the runtime call for `switch` and rename, so both
literal and variable names work (`host.agents.switch("FOO")` or
`host.agents.switch(name_var)`); keep the `ask_user` review step
so the click is a quick confirm, not a surprise. After the call returns,
read back (`host.agents.list()`) to confirm the actual state — don't
narrate an expected card.

### Reading existing profiles

Call `host.agents.list()` to see the user's current profiles so you don't
duplicate an existing agent. The main agent's bundled profile is protected —
it cannot be renamed or deleted.

### 1. Scope first

What is this agent *for*? A profile should have one job. Pick an UPPER_SNAKE
name (`RNASEQ_REVIEWER`, not `RNA-seq review helper`). Use `ask_user` if the
name or scope is unclear — profiles are user-visible in the picker.

### 2. Write the identity

`system_prompt` is the agent's opening paragraph — it replaces the base
identity, it's not an addendum. Lead with `You are {display_name}.` State the
specialization and the boundaries ("You handle X, Y, Z. You do not handle
..."). Keep it under ~200 words; the heavy how-to lives in skills, not the
prompt.

### 3. Ask: full access or a subset?

Before creating, `ask_user` whether this profile should have **full access**
(the live skill catalog and every connector — same reach as the main agent;
new skills and connectors appear automatically) or a **restricted subset**
(a fixed list you'll curate together). Don't infer this from the role
description — a narrowly-described specialist may still want full reach,
and a broadly-described one may want a tight loadout. Pair this with the
name/prompt review in step 4 so it's one round-trip.

### 4. Review with the user

Show the proposed name, display name, description, `system_prompt`, and the
user's full-vs-subset choice from step 3. `ask_user` to confirm before
writing. If they chose a subset, list the proposed skills/connectors here.

### 5. Create

Call `host.agents.create(name, display_name, description, system_prompt=...)`.
If the user chose **full access**, leave `skill_names` unset. If they chose a
**subset**, pass `skill_names=[...]` with the agreed list — **this flips the
profile to curated mode and starts it with zero connectors**, so attach the
agreed connectors after create as in step 6. For edits to an existing profile,
use `host.agents.update(name, {...})` with targeted fields; prefer
`host.agents.attach_skill(...)` / `host.agents.detach_skill(...)` and
`host.agents.attach_connector(...)` / `host.agents.detach_connector(...)`
over wholesale `skill_names` replacement so you don't clobber the user's own
edits.

After the profile exists, **offer to switch to it**:
`host.agents.switch(name)`. The user sees an approval card; on Allow, this
conversation continues as the new specialist from their next message. If they
decline, tell them they can select it from the session config popover on any
new conversation.

### 6. Restricting the loadout (when the user chose a subset)

If the user chose a subset in step 3, curate after create:

- **Skills**: `host.agents.update(name, {"skill_names": [...]})` with the
  exact list, or `detach_skill` one at a time. Check `host.skills.list()`
  for available names. On `update`, `skill_names` is an exact replace — never
  send a partial list to "add"; fetch the current `skillNames` (camelCase in
  the return dict), modify, send back.
- **Connectors**: a curated profile (created with an explicit `skill_names`
  list, or flipped via `{"unrestricted": False}`) starts with **NO
  connectors** — reach is exactly what you attach. Call
  `host.agents.list_connectors()` to see every available connector (bundled +
  directory + user-added MCP) with its auth state, then
  `host.agents.attach_connector(name, connector_name)` for **each** connector
  the user agreed to keep. Use `include_tools_pattern=`/`exclude_tools_pattern=`
  on the attach call to scope which tools the profile gets (omit both to
  expose every tool; re-attaching without patterns preserves the existing
  exclusion list — pass `include_tools_pattern='.*'` to clear). A connector
  with `authState` other than `"authorized"` or `"not-required"` must be
  connected via the Connectors panel before it can be attached.
  `detach_connector` only removes an explicit attachment; on a curated
  profile there is nothing to detach — don't use it to "restrict".
- After attaching, read back `host.agents.get(name)["connectors"]` to confirm
  the reach matches what you told the user.
- To undo a restriction: `host.agents.update(name, {"unrestricted": True})`.

### 7. Set up the environment

After the profile exists, propose a conda environment for it: name it after the
profile (lowercase slug, e.g. `rnaseq-reviewer`), pick `python` or `r` based on
the skills you attached, and list the packages those skills need. `ask_user` to
confirm the package list, then call
`manage_environments(mode="create", name="<slug>", packages=[...])` and, if
anything further is needed,
`manage_packages(mode="install", environment="<slug>", packages=[...])`. The
profile uses this env by default; the system-owned `python` and `r` skeletons
remain available as fallbacks.

### What makes a good profile

- **A sharp identity.** One job, stated clearly in `system_prompt`. Full
  catalog access by default — restrict the loadout only when the user asks.
- **Composable.** Reuse existing skills; don't inline workflow steps into
  `system_prompt` that belong in a skill.
- **Safe by default.** If a connector is powerful, exclude the tools the agent
  doesn't need.

---

## Authoring skills

For the full skill-authoring guide (anatomy, progressive disclosure, eval loop,
description optimization), first load it:

```python
skill({"skill": "skill-creator"})
```

Then use the `host.skills` SDK via the `repl` tool to write and publish:

```python
# draft
host.skills.edit("my-skill", "SKILL.md", """---
name: my-skill
description: ...
---

# My Skill
...
""")

# bundle a helper
host.skills.edit("my-skill", "kernel.py", "def helper(x): ...\n")

# inspect / iterate
print(host.skills.read("my-skill")["content"])

# publish to the live skill set
host.skills.publish("my-skill")
```

Once published, the skill is in the live catalog — every unrestricted profile
(including the main agent and any profile created with the default) sees it
immediately. For a restricted profile, attach it explicitly with
`host.agents.attach_skill(profile_name, "my-skill")`.

---

## Kernel sidecars (`kernel.py` / `kernel.R`)

If a skill's workflow depends on reusable helper functions, ship them as
`kernel.py` (and/or `kernel.R`) at the skill root. When any agent calls
`skill({skill: <name>})`, that file is executed in its persistent python/R kernel and
the tool result reports which top-level names were defined — so SKILL.md can
say "call `annotate_df(df)`" and the function already exists.

Sidecars are validated before execution so that loading a skill only **defines**
names — nothing author-written runs at load time. Allowed at the top level:

- `def` / `async def` (no decorators). Default argument values must be
  literals — `def f(url=MY_CONSTANT)` gets the whole file rejected. Wrap
  constants with an explicit `is None` check: `def f(url=None):` then
  `if url is None: url = MY_CONSTANT` (not `url = url or MY_CONSTANT`,
  which also replaces `0`, `""`, `[]`).
- `import` / `from … import name` (no `*`). Defer third-party imports to
  inside function bodies — the skeleton `python` env ships stdlib + a small
  starter set (numpy, pandas, scipy, matplotlib, seaborn, pillow), so e.g.
  `import requests` at module scope surfaces a load error on every fresh
  kernel. Import errors don't fail the skill load; the agent sees the
  traceback and can `manage_packages` then re-load.
- Assignment of a **literal** constant to a plain name (e.g. `VERSION = "1"`,
  `LIMITS = (1, 2, 3)`). Computed values like `os.path.join(...)` are
  rejected — move them into a function body.

Anything else at the top level (classes, calls, `if`/`for`, non-literal
assigns) is rejected with `[kernel.py rejected] …`. Names starting with `_`
are reserved by the loader and cannot be bound at the top level (use
`import os` and reference `os.path` inside functions, not
`import os as _os`). Function **bodies** are not restricted — they run only
when the agent calls them in a `python` cell.

Keep `kernel.py` small and self-contained (same guidance applies to
`kernel.R`). If a helper wants more than ~100 lines, trim it to the core
operations the agent actually calls; `scripts/` is for standalone CLI
tools run via bash, not for backing the sidecar. For `kernel.py`, the
skill directory is not on `sys.path`, so `from scripts.X import …` fails
— but the directory **is** on disk and readable. A Python sidecar function
can locate it via its own `co_filename` to shell out to a `scripts/` tool:

```python
# kernel.py
import os, sys, subprocess

def run_pipeline(cfg_path):
    here = os.path.dirname(sys._getframe().f_code.co_filename)
    if not here:
        raise RuntimeError("skill dir unavailable in this runtime")
    tool = os.path.join(here, "scripts", "pipeline.py")
    return subprocess.run([sys.executable, tool, cfg_path],
                          capture_output=True, text=True, check=True).stdout
```

`__file__` is not set (sidecars share one kernel namespace, so a global
`__file__` would point at whichever skill loaded last). `co_filename` is
per-function and normally points at this skill's on-disk `kernel.py`; in
rare cases (e.g. when the loader can't resolve the skill dir on disk) it
falls back to the bare name `"kernel.py"` — hence the `if not here` guard
above.

The runtime exports `PYTHONSAFEPATH=1`, which the `subprocess.run` child
inherits — so `python scripts/pipeline.py` does NOT put `scripts/` on the
child's `sys.path`. A multi-file tool that does sibling imports must add
`sys.path.insert(0, os.path.dirname(__file__))` at the top of its entry
script (or pass `env={**os.environ, "PYTHONSAFEPATH": ""}` to
`subprocess.run`). If the child exits non-zero, `capture_output=True`
hides the traceback — inspect `CalledProcessError.stderr`.

`kernel.R` has no runtime analogue of `co_filename` (the R loader parses
with `keep.source=FALSE`), so an R sidecar that needs to reach `scripts/`
should take the path as an argument from the caller.

Minimal example:

```python
# kernel.py
import pandas as pd  # starter-set package — OK at module scope

def annotate_df(df: pd.DataFrame, gene_col: str = "gene") -> pd.DataFrame:
    """Attach HGNC symbols; see SKILL.md ## Workflow step 3."""
    import requests  # not in starter set — defer to function body
    ...
    return df
```

Write sidecars via `host.skills.edit(name, "kernel.py", src)`. When the
gate probe can judge, the edit result carries `sidecar_gate: {ok, error?}`
— the same structural gate the load path runs — so a reject (non-literal
default, `_`-prefixed name, top-level call) surfaces immediately. When the
probe can't judge (interpreter unavailable, or the source doesn't parse
under the host's interpreter — possible version drift), the key is absent
and `note` says why. Iterate against that; don't load the skill just to
test the sidecar. `host.skills.publish` refuses only on a structural
reject.

