Escribir una partícula Python
Una partícula es un programa de un solo archivo que expone una o más herramientas (operaciones con nombre y una entrada JSON-Schema) a un runtime de partículas. Esta habilidad te dice todo lo que necesitas para producir una que compile y funcione.
1. Estructura del archivo
Un solo archivo fuente en la raíz del proyecto, llamado exactamente Particlefile.py.
No se requiere nada más. Las dependencias PyPI se declaran en un bloque de metadatos
en línea PEP 723 al inicio del archivo; la compilación las resuelve.
No hay pyproject.toml, ni virtualenv que gestionar.
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "idna>=3", # opcional — solo si usas paquetes de terceros
# ]
# ///
2. La variable particle a nivel de módulo — toda la API
El archivo debe asignar una instancia de Particle a una variable a nivel de módulo
particle. El runtime la inspecciona durante la compilación y lee los manejadores
en cada llamada de herramienta.
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 _hacer_algo(args):
return {"resultado": f"hola {args['nombre']}"}
def _ping():
return {"status": "ok", "message": "vivo"}
particle = Particle(
name="nombre-en-kebab-case", # requerido, kebab-case
description="...", # requerido, una línea
version="0.1.0", # requerido, semver
# Lista blanca de HTTP saliente. Los hosts no listados aquí son denegados
# en el límite de la red. Omite `http=` por completo si la partícula no
# realiza solicitudes salientes.
http=Http(allowed_hosts=["api.example.com"]),
# Acceso a directorios del host, desactivado por defecto. Ver "Sistema de archivos" más abajo.
# filesystem=Filesystem(mounts={...}, temp={...}),
# Opcional. Declarado por nombre; el usuario elige un método en la configuración.
credentials={
"ejemplo": Credential(
hosts=["api.example.com"],
required=True,
methods={
# ver "Métodos de credencial" más abajo
},
),
},
# Requerido. nombre → Tool(description, input_schema, handler).
tools={
"hacer_algo": Tool(
description="Qué hace esta herramienta — una línea, escrita para un LLM.",
input_schema={
"type": "object",
"properties": {
"nombre": {"type": "string", "description": "..."},
},
"required": ["nombre"],
},
handler=_hacer_algo,
),
},
# Opcional. Llamado por `particle ping`.
ping=_ping,
)
Herramientas
description: escrita para un LLM que llama — corta, orientada a la acción.input_schema: JSON Schema Draft 2020-12, con raíz de objeto. El host valida los argumentos contra él antes de llamar a tu manejador, así que puedes confiar en la forma del dict dentro.handler: un callable simple que toma un dict posicional y devuelve cualquier valor serializable a JSON. Puede serdefoasync def; los manejadores asíncronos se ejecutan en un bucle de sondeo único. Lanza una excepción para exponer un error.
Ping
Opcional. Devuelve un dict como:
{"status": "ok" | "degraded" | "unhealthy",
"message": "...", # opcional
"details": ...} # opcional, serializable a JSON
3. APIs de tiempo de ejecución disponibles para los manejadores
Todas las capacidades del host viven bajo from particle import .... Estos son
los únicos módulos no estándar que necesitas.
HTTP saliente
Cuatro formas equivalentes de hacer una llamada HTTP. Elige la que coincida con el idioma que usa el resto de tu código; todas pasan por la lista blanca del host.
particle.http.fetch — la API nativa. Aplica automáticamente una credencial
cuando se establece credential_name=.
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="ejemplo", # opcional; el host sustituye el secreto
)
if not res.ok:
raise RuntimeError(f"upstream {res.status_code}")
data = res.json()
http.fetch devuelve un Response con:
.status_code(int),.ok(bool),.headers(lista de tuplas(name, bytes)), búsqueda.header(name),.body(bytes),.text(str),.json()(parseado).
urllib.request.urlopen — funciona tal cual. El runtime
lo monkey-patchea para enrutarlo a través de wasi:http.
import urllib.request
with urllib.request.urlopen("https://api.example.com/x") as r:
body = r.read()
urllib3 / requests — funciona tal cual. El runtime inyecta una clase de conexión personalizada
en el momento de importar urllib3, por lo que requests.get(...) etc.
funcionan. No aplican credenciales automáticamente — para
solicitudes autenticadas, usa http.fetch(..., credential_name=...)
o extrae el marcador de posición y establece el encabezado tú mismo.
import requests
r = requests.get("https://api.example.com/x") # funciona
httpx — funciona tal cual, tanto síncrono como asíncrono. Se inyecta un
HTTPTransport / AsyncHTTPTransport personalizado en la importación.
import httpx
r = httpx.get("https://api.example.com/x")
Los sockets sin procesar no están disponibles. socket.socket() lanza OSError.
Sistema de archivos
Desactivado por defecto — un manejador solo ve su propio paquete. Declara una
capacidad Filesystem para obtener acceso al directorio del host, luego lee y
escribe con open() / pathlib normales (el runtime los enruta a través
de wasi:filesystem).
from particle.manifest import Filesystem, Mount, TempMount
filesystem=Filesystem(
# Directorios del host que el *usuario* asigna a rutas reales — de forma persistente
# con `particle mount <particle> <nombre> <ruta-host>`, o por ejecución
# con `--mount <nombre>=<ruta-host>`.
mounts={
"datos": Mount(
description="Donde se escriben los informes", # se muestra en el mensaje de instalación
path="/mnt/datos", # ruta absoluta dentro del sandbox
access="readwrite", # "readonly" | "readwrite"
required=True, # rechaza ejecutarse hasta que se asigne (por defecto False)
),
},
# Espacio temporal que el host provisiona fresco cada ejecución y limpia al
# salir. Siempre lectura-escritura; el usuario nunca los asigna.
temp={
"trabajo": TempMount(description="Espacio temporal", path="/tmp/trabajo", max_size="10MB"),
},
),
with open("/mnt/datos/in.json") as f:
data = f.read()
with open("/mnt/datos/out.json", "w") as f:
f.write(result)
- El manejador abre directamente la
pathdeclarada; el nombre del montaje nunca aparece dentro del sandbox. - Escribir en un montaje
readonly, o leer/escribir cualquier ruta fuera de un montaje declarado, lanzaOSError. - Un montaje
requiredque el usuario nunca asigna falla en tiempo de ejecución, no en compilación/instalación. max_sizees un conteo de bytes con un sufijo opcionalKB/MB/GB("10MB"); las escrituras que lo superen fallan.
Credenciales
from particle import credentials
# El método que el usuario eligió en la configuración ("oauth", "pat", ...) o None.
method = credentials.get_configured_method("ejemplo")
# Valor sin procesar de una credencial de tipo "raw".
value = credentials.get_raw("alguna-cred-raw")
# Bajo nivel: obtener un marcador de posición opaco + especificación de aplicación que describe
# dónde el host espera sustituir el secreto. Casi siempre quieres
# `http.fetch(..., credential_name=...)` en su lugar, que hace esto
# internamente.
info = credentials.get_placeholder("ejemplo")
Almacenamiento clave/valor
Alcance por partícula. Solo cadenas — codifica en base64 binario si es necesario.
Decláralo con kv=KV(enabled=True) en la Particle — sin la
declaración, cada llamada lanza un error not-declared (no se necesita aprobación del
usuario, solo la declaración):
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("prefijo:") # list[str] — nota: oculta el builtin
Importa el módulo como kv (no from particle.kv import list) para que
list no oculte el builtin.
Actualización OAuth
Solo si tienes una credencial OAuth2. Las llamadas a http.fetch con
credential_name= actualizan automáticamente tokens caducados; llama a esto solo cuando
un upstream rechaza un token que pensaste que seguía siendo válido.
from particle import oauth
oauth.refresh("ejemplo")
Firma
Solo para credenciales SigningKey. El material clave nunca entra en Python.
from particle import signing
sig = signing.sign("webhook-key", bytes_payload)
ok = signing.verify("webhook-key", bytes_datos, bytes_firma)
4. Métodos de credencial
Cada Credential tiene un mapa methods={...}. El usuario elige uno en la
configuración. Los tipos de dataclass provienen de particle.manifest.
OAuth2
"oauth": OAuth2(
description="Iniciar sesión mediante 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", # para "device-code"
),
Cadenas de flujo válidas: "authorization-code", "authorization-code-pkce",
"device-code".
ApiKey
"pat": ApiKey(
description="Usar un token de acceso personal",
location=ApiKeyLocation(kind="auth-scheme", scheme="Bearer"),
),
ApiKeyLocation.kind es uno de:
"header"+name:<name>: <key>"auth-scheme"+scheme:Authorization: <scheme> <key>(ej.Bearer)"query-param"+name: añadido como?<name>=<key>
Omite location por completo para que el importador pregunte al usuario en la configuración.
Basic
"basic_auth": Basic(description="Usuario + contraseña"),
Se sustituye como Authorization: Basic <base64>.
SigningKey
"webhook": SigningKey(description="...", algorithm="hmac-sha256"),
Válidos: "hmac-sha256", "hmac-sha512". Se usa mediante signing.sign.
Raw
"api_key_blob": Raw(description="..."),
Se usa mediante credentials.get_raw(nombre). Elige esto solo cuando realmente
necesites los bytes sin procesar — los otros tipos son más seguros porque
el valor nunca entra en la memoria de Python.
5. Dependencias PyPI
Declara en el bloque PEP 723 al inicio del archivo:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.27",
# "pyjwt>=2",
# ]
# ///
Restricciones:
- Solo ruedas Pure-Python. Cualquier cosa que necesite una extensión C (numpy, pydantic v1, lxml, ...) no se cargará.
- La biblioteca estándar está en gran medida disponible —
json,re,hashlib,hmac,base64,secrets,datetime,urllib.parse,asyncio,email, etc., todos funcionan. ssl,socketsin procesar,subprocess— no disponibles.open()/pathlibfuncionan solo para rutas dentro de un montaje del sistema de archivos declarado (ver "Sistema de archivos" arriba) y el propio paquete/particle/de la partícula; las rutas arbitrarias del host son denegadas.
6. Reglas críticas
-
Sin llamadas al host en el ámbito del módulo. Llamar a
http.fetch,kv.get,credentials.get_placeholder, etc. al inicio del archivo fallará en tiempo de compilación (la fase de introspección ejecuta tu módulo bajo trampas de almacenamiento). Haz cada llamada al host dentro de una funciónhandleroping.# ✗ Fallará la compilación _datos_cache = http.fetch("https://api.example.com/init") particle = Particle(...) # ✓ Dentro del manejador def _ir(args): res = http.fetch("https://api.example.com/...") ... particle = Particle(tools={"ir": Tool(..., handler=_ir)}) -
Declara cada host al que llames. Una solicitud a un host que falte en
http=Http(allowed_hosts=[...])es denegada. El error se manifiesta como una excepción en el manejador. -
Credential.hostsdebe ser un subconjunto de la lista blanca HTTP. La compilación rechaza credenciales vinculadas a hosts a los que la partícula no puede acceder. -
namedebe ser kebab-case (minúsculas, guiones). La clave del registro es(name, version). -
versiondebe ser semver válido.0.1.0,1.2.3-rc.1, etc. -
Los manejadores de herramientas deben devolver valores serializables a JSON.
dict,list,str,int,float,bool,Nonesimples. Un dataclass odatetimefallará al serializarse — convierte antes de devolver.
7. Flujo de trabajo
Después de escribir Particlefile.py:
particle build # parse, resuelve dependencias, inspecciona, registra
particle ping <nombre> # verificar ping (si está definido)
particle run <nombre> # listar herramientas
particle run <nombre> <herramienta> --help # mostrar banderas de la herramienta
particle run <nombre> <herramienta> --foo=bar # ejecutarla
particle build guía al usuario a través de la configuración de credenciales de forma interactiva
la primera vez. Si la compilación falla, el error nombra la fase
(import-scan, resolve-and-fetch, manifest-extract) y el
problema específico.
Si la partícula declara montajes del sistema de archivos, asígnalos antes de ejecutar
(o pasa --mount nombre=ruta en particle run para una ejecución única):
particle mount <nombre> <nombre-montaje> <ruta-host> # guarda un mapeo persistente
particle mount <nombre> # listar montajes + mapeos actuales
Para exponer la partícula como un ejecutable independiente (útil para habilidades de Claude Code
y uso en shell), particle link <nombre> ./<nombre> escribe un lanzador que
reenvía sus argumentos a particle run <nombre>.
8. Ejemplo práctico
Una partícula mínima que expone una herramienta de consulta de repositorio de GitHub:
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
import json
from particle import http
from particle.manifest import Particle, Tool, Http, Credential, ApiKey, ApiKeyLocation
def _obtener_repo(args):
res = http.fetch(
f"https://api.github.com/repos/{args['propietario']}/{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"),
"estrellas": r.get("stargazers_count"),
"descripción": r.get("description"),
"url": r.get("html_url"),
}
particle = Particle(
name="consulta-github",
description="Obtener detalles de un repositorio público de GitHub",
version="0.1.0",
http=Http(allowed_hosts=["api.github.com"]),
credentials={
"github": Credential(
hosts=["api.github.com"],
required=False,
methods={
"pat": ApiKey(
description="Token de acceso personal de GitHub",
location=ApiKeyLocation(kind="auth-scheme", scheme="Bearer"),
),
},
),
},
tools={
"consultar_repo": Tool(
description="Obtener metadatos de un repositorio de GitHub",
input_schema={
"type": "object",
"properties": {
"propietario": {"type": "string", "description": "Propietario del repositorio"},
"repo": {"type": "string", "description": "Nombre del repositorio"},
},
"required": ["propietario", "repo"],
},
handler=_obtener_repo,
),
},
)
Compílalo y pruébalo:
particle build
particle run consulta-github consultar_repo --propietario=microsoft --repo=vscode