Escreva uma partícula Python
Uma partícula é um programa de arquivo único que expõe uma ou mais ferramentas (operações nomeadas com entrada JSON-Schema) a um runtime de partículas. Esta habilidade fornece tudo que você precisa para produzir uma que compile e execute.
1. Estrutura do arquivo
Um arquivo fonte na raiz do projeto, nomeado exatamente Particlefile.py.
Nada mais é necessário. As dependências PyPI são declaradas em um bloco
inline de metadados PEP 723 no topo do arquivo; a construção as resolve.
Sem pyproject.toml, sem virtualenv para gerenciar.
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "idna>=3", # opcional — apenas se usar pacotes de terceiros
# ]
# ///
2. O particle no nível do módulo — a API completa
O arquivo deve atribuir uma instância de Particle a uma variável
particle no nível do módulo. O runtime a inspeciona na construção,
e lê os manipuladores a cada chamada de ferramenta.
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"olá {args['name']}"}
def _ping():
return {"status": "ok", "message": "vivo"}
particle = Particle(
name="nome-em-kebab-case", # obrigatório, kebab-case
description="...", # obrigatório, uma linha
version="0.1.0", # obrigatório, semver
# Lista de permissões de HTTP de saída. Hosts não listados aqui são negados no
# limite da rede. Omita `http=` se a partícula não fizer
# nenhuma requisição de saída.
http=Http(allowed_hosts=["api.example.com"]),
# Acesso ao diretório do host, desligado por padrão. Veja "Sistema de arquivos" abaixo.
# filesystem=Filesystem(mounts={...}, temp={...}),
# Opcional. Declarado por nome; o usuário escolhe um método na configuração.
credentials={
"exemplo": Credential(
hosts=["api.example.com"],
required=True,
methods={
# veja "Métodos de credencial" abaixo
},
),
},
# Obrigatório. nome → Tool(descrição, esquema_de_entrada, manipulador).
tools={
"fazer_coisa": Tool(
description="O que esta ferramenta faz — uma linha, escrita para um LLM.",
input_schema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "..."},
},
"required": ["name"],
},
handler=_do_thing,
),
},
# Opcional. Chamado por `particle ping`.
ping=_ping,
)
Ferramentas
description: escrita para um chamador LLM — curta, orientada à ação.input_schema: JSON Schema Draft 2020-12, enraizado em objeto. O host valida os argumentos contra ele antes de chamar seu manipulador, então você pode confiar na forma do dict internamente.handler: um callable simples que recebe um dict posicional e retorna qualquer valor serializável em JSON. Pode serdefouasync def; manipuladores assíncronos executam em um loop de polling único. Levante para expor um erro.
Ping
Opcional. Retorne um dict como:
{"status": "ok" | "degraded" | "unhealthy",
"message": "...", # opcional
"details": ...} # opcional, serializável em JSON
3. APIs de runtime disponíveis para manipuladores
Todas as capacidades do host estão sob from particle import .... Estes são
os únicos módulos não padrão que você precisa.
HTTP de saída
Quatro maneiras equivalentes de fazer uma chamada HTTP. Escolha aquela que corresponde ao idioma que o resto do seu código usa; todas passam pela lista de permissões do host.
particle.http.fetch — a API nativa. Aplica automaticamente uma credencial
quando credential_name= está definido.
from particle import http
res = http.fetch(
"https://api.example.com/coisas",
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps({...}).encode(),
credential_name="exemplo", # opcional; o host substitui o segredo
)
if not res.ok:
raise RuntimeError(f"upstream {res.status_code}")
data = res.json()
http.fetch retorna uma Response com:
.status_code(int),.ok(bool),.headers(lista de tuplas(nome, bytes)),.header(nome)consulta,.body(bytes),.text(str),.json()(analisado).
urllib.request.urlopen — funciona como está. O runtime
monkey-patcha ele para rotear através de wasi:http.
import urllib.request
with urllib.request.urlopen("https://api.example.com/x") as r:
body = r.read()
urllib3 / requests — funcionam como estão. O runtime injeta uma classe
de conexão personalizada no momento da importação do urllib3, então
requests.get(...) etc. simplesmente funcionam. Eles não aplicam credenciais automaticamente — para
requisições autenticadas, use http.fetch(..., credential_name=...)
ou puxe o placeholder e defina o cabeçalho você mesmo.
import requests
r = requests.get("https://api.example.com/x") # funciona
httpx — funciona como está, tanto síncrono quanto assíncrono.
HTTPTransport / AsyncHTTPTransport personalizado injetado na importação.
import httpx
r = httpx.get("https://api.example.com/x")
Sockets brutos não estão disponíveis. socket.socket() levanta OSError.
Sistema de arquivos
Desligado por padrão — um manipulador vê apenas seu próprio pacote. Declare
uma capacidade Filesystem para obter acesso ao diretório do host, depois leia e
escreva com open() / pathlib comuns (o runtime os roteia
através de wasi:filesystem).
from particle.manifest import Filesystem, Mount, TempMount
filesystem=Filesystem(
# Diretórios do host que o *usuário* mapeia para caminhos reais — persistentemente
# com `particle mount <particle> <nome> <caminho-host>`, ou por execução
# com `--mount <nome>=<caminho-host>`.
mounts={
"dados": Mount(
description="Onde os relatórios são escritos", # mostrado no prompt de instalação
path="/mnt/dados", # caminho absoluto dentro do sandbox
access="readwrite", # "readonly" | "readwrite"
required=True, # recusa executar até mapeado (padrão False)
),
},
# Espaço temporário que o host provisiona fresco a cada execução e limpa na
# saída. Sempre leitura-escrita; o usuário nunca mapeia estes.
temp={
"trabalho": TempMount(description="Espaço temporário", path="/tmp/trabalho", max_size="10MB"),
},
),
with open("/mnt/dados/in.json") as f:
data = f.read()
with open("/mnt/dados/out.json", "w") as f:
f.write(result)
- O manipulador abre o
pathdeclarado diretamente; o nome da montagem nunca aparece dentro do sandbox. - Escrever em uma montagem
readonly, ou ler/escrever qualquer caminho fora de uma montagem declarada, levantaOSError. - Uma montagem
requiredque o usuário nunca mapeia falha em tempo de execução, não construção/instalação. max_sizeé uma contagem de bytes com um sufixo opcionalKB/MB/GB("10MB"); escritas além disso falham.
Credenciais
from particle import credentials
# O método que o usuário escolheu na configuração ("oauth", "pat", ...) ou None.
method = credentials.get_configured_method("exemplo")
# Valor bruto de uma credencial do tipo "raw".
value = credentials.get_raw("alguma-cred-raw")
# Baixo nível: obter um placeholder opaco + especificação de aplicação descrevendo onde
# o host espera substituí-lo. Quase sempre você quer
# `http.fetch(..., credential_name=...)` em vez disso, que faz isso
# internamente.
info = credentials.get_placeholder("exemplo")
Armazenamento chave/valor
Escopo por partícula. Apenas strings — codifique base64 binário se necessário.
Declare-o com kv=KV(enabled=True) no Particle — sem a
declaração, cada chamada levanta um erro not-declared (nenhuma aprovação do usuário
é necessária, apenas a declaração):
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("prefixo:") # list[str] — note: sombreia o builtin
Importe o módulo como kv (não from particle.kv import list) para que
list não sombreie o builtin.
Refresh OAuth
Apenas se você tiver uma credencial OAuth2. Chamadas a http.fetch com
credential_name= renovam automaticamente tokens expirados; chame isso apenas quando
um upstream rejeitar um token que você pensava ser ainda válido.
from particle import oauth
oauth.refresh("exemplo")
Assinatura
Apenas para credenciais SigningKey. O material da chave nunca entra no Python.
from particle import signing
sig = signing.sign("webhook-key", payload_bytes)
ok = signing.verify("webhook-key", data_bytes, signature_bytes)
4. Métodos de credencial
Cada Credential tem um mapa methods={...}. O usuário escolhe um na
configuração. Os tipos dataclass vêm de particle.manifest.
OAuth2
"oauth": OAuth2(
description="Entrar 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", # para "device-code"
),
Strings de fluxo válidas: "authorization-code", "authorization-code-pkce",
"device-code".
ApiKey
"pat": ApiKey(
description="Usar um token de acesso pessoal",
location=ApiKeyLocation(kind="auth-scheme", scheme="Bearer"),
),
ApiKeyLocation.kind é um de:
"header"+name:<name>: <key>"auth-scheme"+scheme:Authorization: <scheme> <key>(ex:Bearer)"query-param"+name: anexado como?<name>=<key>
Omita location completamente para deixar o importador solicitar ao usuário na configuração.
Basic
"basic_auth": Basic(description="Nome de usuário + senha"),
Substitui como Authorization: Basic <base64>.
SigningKey
"webhook": SigningKey(description="...", algorithm="hmac-sha256"),
Válido: "hmac-sha256", "hmac-sha512". Use via signing.sign.
Raw
"api_key_blob": Raw(description="..."),
Use via credentials.get_raw(nome). Escolha isso apenas quando você
genuinamente precisar dos bytes brutos — os outros tipos são mais seguros porque
o valor nunca entra na memória do Python.
5. Dependências PyPI
Declare no bloco PEP 723 no topo do arquivo:
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx>=0.27",
# "pyjwt>=2",
# ]
# ///
Restrições:
- Apenas wheels Pure-Python. Qualquer coisa que precise de uma extensão C (numpy, pydantic v1, lxml, ...) não carregará.
- A biblioteca padrão está amplamente disponível —
json,re,hashlib,hmac,base64,secrets,datetime,urllib.parse,asyncio,email, etc. todos funcionam. ssl,socketbruto,subprocess— não disponíveis.open()/pathlibfuncionam apenas para caminhos dentro de uma montagem de sistema de arquivos declarada (veja "Sistema de arquivos" acima) e do próprio pacote/particle/da partícula; caminhos arbitrários do host são negados.
6. Regras críticas
-
Sem chamadas de host no escopo do módulo. Chamar
http.fetch,kv.get,credentials.get_placeholder, etc. no topo do arquivo falhará no momento da construção (a fase de introspecção executa seu módulo sob armadilhas de armazenamento). Faça toda chamada de host dentro de uma funçãohandlerouping.# ✗ Falhará a construção _cached_data = http.fetch("https://api.example.com/init") particle = Particle(...) # ✓ Dentro do manipulador def _go(args): res = http.fetch("https://api.example.com/...") ... particle = Particle(tools={"go": Tool(..., handler=_go)}) -
Declare todo host que você buscar. Uma requisição a um host ausente de
http=Http(allowed_hosts=[...])é negada. O erro surge como uma exceção no manipulador. -
Credential.hostsdeve ser um subconjunto da lista de permissões HTTP. A construção rejeita credenciais vinculadas a hosts que a partícula não pode alcançar. -
namedeve ser kebab-case (minúsculas, hífens). A chave do registro é(name, version). -
versiondeve ser semver válido.0.1.0,1.2.3-rc.1, etc. -
Manipuladores de ferramenta devem retornar valores serializáveis em JSON.
dict,list,str,int,float,bool,Nonesimples. Uma dataclass oudatetimefalhará ao serializar — converta antes de retornar.
7. Fluxo de trabalho
Após escrever Particlefile.py:
particle build # analisar, resolver deps, inspecionar, registrar
particle ping <nome> # verificar ping (se definido)
particle run <nome> # listar ferramentas
particle run <nome> <ferramenta> --help # mostrar flags da ferramenta
particle run <nome> <ferramenta> --foo=bar # invocá-la
particle build guia o usuário pela configuração de credenciais interativamente
na primeira vez. Se a construção falhar, o erro nomeia a fase
(import-scan, resolve-and-fetch, manifest-extract) e o
problema específico.
Se a partícula declarar montagens de sistema de arquivos, mapeie-as antes de executar
(ou passe --mount name=path em particle run para uma execução única):
particle mount <nome> <nome-montagem> <caminho-host> # salvar um mapeamento persistente
particle mount <nome> # listar montagens + mapeamentos atuais
Para expor a partícula como um executável independente (útil para habilidades
Claude Code e uso em shell), particle link <nome> ./<nome> escreve um
lançador que repassa seus argumentos para particle run <nome>.
8. Exemplo trabalhado
Uma partícula mínima expondo uma ferramenta de consulta de repositório 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"),
"language": r.get("language"),
"private": r.get("private"),
"url": r.get("html_url"),
}
particle = Particle(
name="github-repo-lookup",
description="Busca informações de um repositório público do 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="Token de acesso pessoal do GitHub",
location=ApiKeyLocation(kind="auth-scheme", scheme="Bearer"),
),
},
),
},
tools={
"get_repo": Tool(
description="Busca informações de um repositório público do GitHub por proprietário e nome.",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string", "description": "Proprietário do repositório"},
"repo": {"type": "string", "description": "Nome do repositório"},
},
"required": ["owner", "repo"],
},
handler=_get_repo,
),
},
)