Écrire une Particle Python
Une particle est un programme monofichier qui expose un ou plusieurs outils (opérations nommées avec une entrée JSON-Schema) à un runtime Particle. Cette compétence vous apprend tout ce qu'il faut pour en produire une qui se compile et s'exécute.
1. Organisation du fichier
Un seul fichier source à la racine du projet, nommé exactement Particlefile.py.
Rien d'autre n'est requis. Les dépendances PyPI sont déclarées dans un bloc de
métadonnées en ligne PEP 723 en haut du fichier ; la compilation les résout.
Pas de pyproject.toml, pas de virtualenv à gérer.
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "idna>=3", # optionnel — uniquement si vous utilisez des packages tiers
# ]
# ///
2. particle au niveau du module — toute l'API
Le fichier doit assigner une instance de Particle à une variable particle
au niveau du module. Le runtime l'inspecte à la compilation et lit les
gestionnaires à chaque appel d'outil.
from particle.manifest import (
Particle, Tool, Http, Filesystem, Mount, TempMount,
Credential, OAuth2, ApiKey, ApiKeyLocation, SigningKey, Basic, Raw,
)
from particle import http, credentials, kv, oauth, signing
def _do_thing(args):
return {"result": f"hello {args['name']}"}
def _ping():
return {"status": "ok", "message": "alive"}
particle = Particle(
name="kebab-case-name", # requis, en kebab-case
description="...", # requis, une ligne
version="0.1.0", # requis, semver
# Liste blanche HTTP sortante. Les hôtes non listés ici sont refusés à
# la limite du réseau. Omettez complètement `http=` si la particle
# n'effectue aucune requête sortante.
http=Http(allowed_hosts=["api.example.com"]),
# Accès aux répertoires hôtes, désactivé par défaut. Voir "Système de
# fichiers" ci-dessous.
# filesystem=Filesystem(mounts={...}, temp={...}),
# Optionnel. Déclaré par nom ; l'utilisateur choisit une méthode à
# l'installation.
credentials={
"example": Credential(
hosts=["api.example.com"],
required=True,
methods={
# voir "Méthodes d'identifiants" ci-dessous
},
),
},
# Requis. nom → Tool(description, input_schema, handler).
tools={
"do_thing": Tool(
description="Ce que fait cet outil — une ligne, écrite pour un LLM.",
input_schema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "..."},
},
"required": ["name"],
},
handler=_do_thing,
),
},
# Optionnel. Appelé par `particle ping`.
ping=_ping,
)
Outils
description: écrite pour un appelant LLM — courte, orientée action.input_schema: JSON Schema Draft 2020-12, ancré sur un objet. L'hôte valide les arguments par rapport à celui-ci avant d'appeler votre gestionnaire, vous pouvez donc faire confiance à la forme du dict à l'intérieur.handler: un appelable simple prenant un dict positionnel et retournant n'importe quelle valeur sérialisable en JSON. Peut êtredefouasync def; les gestionnaires asynchrones s'exécutent sur une boucle d'interrogation unique. Levez une exception pour signaler une erreur.
Ping
Optionnel. Retournez un dict comme :
{"status": "ok" | "degraded" | "unhealthy",
"message": "...", # optionnel
"details": ...} # optionnel, sérialisable en JSON
3. API runtime disponibles pour les gestionnaires
Toutes les capacités de l'hôte se trouvent sous from particle import .... Ce
sont les seuls modules non-standard dont vous avez besoin.
HTTP sortant
Quatre façons équivalentes d'effectuer un appel HTTP. Choisissez celle qui correspond à l'idiome utilisé par le reste de votre code ; elles passent toutes par la liste blanche de l'hôte.
particle.http.fetch — l'API native. Applique automatiquement un
identifiant lorsque credential_name= est défini.
from particle import http
res = http.fetch(
"https://api.example.com/things",
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps({...}).encode(),
credential_name="example", # optionnel ; l'hôte substitue le secret
)
if not res.ok:
raise RuntimeError(f"upstream {res.status_code}")
data = res.json()
http.fetch retourne un Response avec :
.status_code(int),.ok(bool),.headers(liste de tuples(name, bytes)),.header(name)pour la recherche,.body(bytes),.text(str),.json()(analysé).
urllib.request.urlopen — fonctionne tel quel. Le runtime le
monkey-patche pour le router via wasi:http.
import urllib.request
with urllib.request.urlopen("https://api.example.com/x") as r:
body = r.read()
urllib3 / requests — fonctionnent tels quels. Le runtime injecte une
classe de connexion personnalisée au moment de l'import de urllib3, donc
requests.get(...) etc. fonctionnent. Ils n'appliquent pas les identifiants
automatiquement — pour les requêtes authentifiées, utilisez soit
http.fetch(..., credential_name=...) soit récupérez le placeholder et
définissez l'en-tête vous-même.
import requests
r = requests.get("https://api.example.com/x") # fonctionne
httpx — fonctionne tel quel, synchrone et asynchrone.
HTTPTransport / AsyncHTTPTransport personnalisé injecté à l'import.
import httpx
r = httpx.get("https://api.example.com/x")
Les sockets bruts ne sont pas disponibles. socket.socket() lève
OSError.
Système de fichiers
Désactivé par défaut — un gestionnaire ne voit que son propre bundle.
Déclarez une capacité Filesystem pour obtenir l'accès au répertoire hôte,
puis lisez et écrivez avec open() / pathlib ordinaires (le runtime les
route via wasi:filesystem).
from particle.manifest import Filesystem, Mount, TempMount
filesystem=Filesystem(
# Répertoires hôtes que *l'utilisateur* mappe vers des chemins réels — de
# manière persistante avec `particle mount <particle> <nom> <chemin-hôte>`,
# ou par exécution avec `--mount <nom>=<chemin-hôte>`.
mounts={
"data": Mount(
description="Où les rapports sont écrits", # affiché dans l'invite d'installation
path="/mnt/data", # chemin absolu dans le sandbox
access="readwrite", # "readonly" | "readwrite"
required=True, # refuse de s'exécuter tant que non mappé (par défaut False)
),
},
# Espace temporaire que l'hôte provisionne fraîchement à chaque exécution
# et efface à la sortie. Toujours en lecture-écriture ; l'utilisateur ne
# mappe jamais ces chemins.
temp={
"work": TempMount(description="Espace temporaire", path="/tmp/work", max_size="10MB"),
},
),
with open("/mnt/data/in.json") as f:
data = f.read()
with open("/mnt/data/out.json", "w") as f:
f.write(result)
- Le gestionnaire ouvre directement le
pathdéclaré ; le nom du montage n'apparaît jamais dans le sandbox. - Écrire sur un montage
readonly, ou lire/écrire tout chemin en dehors d'un montage déclaré, lèveOSError. - Un montage
requiredque l'utilisateur ne mappe jamais échoue au moment de l'exécution, pas à la compilation/installation. max_sizeest un nombre d'octets avec un suffixe optionnelKB/MB/GB("10MB") ; les écritures au-delà échouent.
Identifiants
from particle import credentials
# La méthode que l'utilisateur a choisie à l'installation ("oauth", "pat", ...) ou None.
method = credentials.get_configured_method("example")
# Valeur brute d'un identifiant de type "raw".
value = credentials.get_raw("some-raw-cred")
# Bas niveau : obtenir un placeholder opaque + une spécification d'application
# décrivant où l'hôte attend de substituer le secret. Presque toujours vous
# voulez `http.fetch(..., credential_name=...)` à la place, qui fait cela
# en interne.
info = credentials.get_placeholder("example")
Magasin clé/valeur
Limité à chaque particle. Chaînes uniquement — encodez en base64 les données
binaires si nécessaire. Déclarez-le avec kv=KV(enabled=True) sur le
Particle — sans la déclaration, chaque appel lève une erreur not-declared
(aucune approbation utilisateur n'est nécessaire, juste la déclaration) :
from particle.manifest import Particle, KV
# particle = Particle(..., kv=KV(enabled=True))
from particle import kv
kv.set("cursor", "abc123")
v = kv.get("cursor") # str | None
kv.delete("cursor")
keys = kv.list("prefix:") # list[str] — note : masque le builtin
Importez le module en tant que kv (pas from particle.kv import list) pour
que list ne masque pas le builtin.
Rafraîchissement OAuth
Uniquement si vous avez un identifiant OAuth2. Les appels à http.fetch avec
credential_name= rafraîchissent automatiquement les jetons expirés ;
appelez ceci uniquement lorsqu'un amont rejette un jeton que vous pensiez
encore valide.
from particle import oauth
oauth.refresh("example")
Signature
Uniquement pour les identifiants SigningKey. Le matériel de clé n'entre jamais
dans Python.
from particle import signing
sig = signing.sign("webhook-key", payload_bytes)
ok = signing.verify("webhook-key", data_bytes, signature_bytes)
4. Méthodes d'identifiants
Chaque Credential a une carte methods={...}. L'utilisateur en choisit une
à l'installation. Les types de dataclass proviennent de particle.manifest.
OAuth2
"oauth": OAuth2(
description="Se connecter via OAuth",
flows=["authorization-code", "device-code"],
scopes=["read", "write"],
authorization_url="https://provider/oauth/authorize",
token_url="https://provider/oauth/token",
device_auth_url="https://provider/oauth/device/code", # pour "device-code"
),
Chaînes de flux valides : "authorization-code", "authorization-code-pkce",
"device-code".
ApiKey
"pat": ApiKey(
description="Utiliser un jeton d'accès personnel",
location=ApiKeyLocation(kind="auth-scheme", scheme="Bearer"),
),
ApiKeyLocation.kind est l'un de :
"header"+name:<name>: <key>"auth-scheme"+scheme:Authorization: <scheme> <key>(par ex.Bearer)"query-param"+name: ajouté comme?<name>=<key>
Omettez complètement location pour laisser l'importateur inviter
l'utilisateur à l'installation.
Basic
"basic_auth": Basic(description="Nom d'utilisateur + mot de passe"),
Substitue comme Authorization: Basic <base64>.
SigningKey
"webhook": SigningKey(description="...", algorithm="hmac-sha256"),
Valides : "hmac-sha256", "hmac-sha512". Utilisez via signing.sign.
Raw
"api_key_blob": Raw(description="..."),
Utilisez via credentials.get_raw(name). Choisissez ceci uniquement lorsque
vous avez vraiment besoin des octets bruts — les autres types sont plus sûrs
car la valeur n'entre jamais dans la mémoire Python.
5. Dépendances PyPI
Déclarez dans le bloc PEP 723 en haut du fichier :
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.27",
# "pyjwt>=2",
# ]
# ///
Contraintes :
- Wheels pure-Python uniquement. Tout ce qui nécessite une extension C (numpy, pydantic v1, lxml, ...) ne se chargera pas.
- La bibliothèque standard est largement disponible —
json,re,hashlib,hmac,base64,secrets,datetime,urllib.parse,asyncio,email, etc. fonctionnent tous. ssl,socketbrut,subprocess— pas disponibles.open()/pathlibfonctionnent uniquement pour les chemins à l'intérieur d'un montage de système de fichiers déclaré (voir "Système de fichiers" ci-dessus) et du propre bundle/particle/de la particle ; les chemins hôtes arbitraires sont refusés.
6. Règles critiques
-
Pas d'appels hôtes au niveau du module. Appeler
http.fetch,kv.get,credentials.get_placeholder, etc. en haut du fichier échouera au moment de la compilation (la phase d'inspection exécute votre module sous des magasins de pièges). Faites chaque appel hôte à l'intérieur d'une fonctionhandlerouping.# ✗ Échouera la compilation _cached_data = http.fetch("https://api.example.com/init") particle = Particle(...) # ✓ À l'intérieur du gestionnaire def _go(args): res = http.fetch("https://api.example.com/...") ... particle = Particle(tools={"go": Tool(..., handler=_go)}) -
Déclarez chaque hôte que vous appelez. Une requête vers un hôte manquant dans
http=Http(allowed_hosts=[...])est refusée. L'erreur se présente comme une exception dans le gestionnaire. -
Credential.hostsdoit être un sous-ensemble de la liste blanche HTTP. La compilation rejette les identifiants liés à des hôtes que la particle ne peut pas atteindre. -
namedoit être en kebab-case (minuscules, traits d'union). La clé du registre est(name, version). -
versiondoit être un semver valide.0.1.0,1.2.3-rc.1, etc. -
Les gestionnaires d'outils doivent retourner des valeurs sérialisables en JSON.
dict,list,str,int,float,bool,Nonesimples. Un dataclass oudatetimeéchouera à la sérialisation — convertissez avant de retourner.
7. Flux de travail
Après avoir écrit Particlefile.py :
particle build # analyser, résoudre les dépendances, inspecter, enregistrer
particle ping <name> # vérifier le ping (si défini)
particle run <name> # lister les outils
particle run <name> <tool> --help # afficher les indicateurs de l'outil
particle run <name> <tool> --foo=bar # l'invoquer
particle build guide l'utilisateur à travers la configuration des
identifiants de manière interactive la première fois. Si la compilation
échoue, l'erreur nomme la phase (import-scan, resolve-and-fetch,
manifest-extract) et le problème spécifique.
Si la particle déclare des montages de système de fichiers, mappez-les avant
d'exécuter (ou passez --mount name=path sur particle run pour un
usage unique) :
particle mount <name> <mount-name> <host-path> # enregistrer un mappage persistant
particle mount <name> # lister les montages + mappages actuels
Pour exposer la particle en tant qu'exécutable autonome (pratique pour les
compétences Claude Code et l'utilisation en ligne de commande),
particle link <name> ./<name> écrit un lanceur qui transmet ses arguments
à particle run <name>.
8. Exemple concret
Une particle minimale exposant un outil de recherche de dépôt GitHub :
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
import json
from particle import http
from particle.manifest import Particle, Tool, Http, Credential, ApiKey, ApiKeyLocation
def _get_repo(args):
res = http.fetch(
f"https://api.github.com/repos/{args['owner']}/{args['repo']}",
headers={"Accept": "application/vnd.github+json"},
credential_name="github",
)
if not res.ok:
raise RuntimeError(f"GitHub {res.status_code}: {res.text}")
r = res.json()
return {
"full_name": r.get("full_name"),
"stars": r.get("stargazers_count"),
"url": r.get("html_url"),
"description": r.get("description"),
}
particle = Particle(
name="github-lookup",
description="Rechercher un dépôt GitHub",
version="0.1.0",
http=Http(allowed_hosts=["api.github.com"]),
credentials={
"github": Credential(
hosts=["api.github.com"],
required=True,
methods={
"pat": ApiKey(
description="Jeton d'accès personnel GitHub",
location=ApiKeyLocation(kind="header", name="Authorization"),
),
},
),
},
tools={
"lookup-repo": Tool(
description="Obtenir les détails d'un dépôt GitHub par propriétaire et nom de dépôt.",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string", "description": "Propriétaire du dépôt (nom d'utilisateur ou organisation)"},
"repo": {"type": "string", "description": "Nom du dépôt"},
},
"required": ["owner", "repo"],
},
handler=_get_repo,
),
},
)