Skill Fulfillment Contract v1
The Skill Fulfillment Contract v1 is the wire contract Sill honors when your own HTTPS endpoint fulfills one of the five read-side default skills. It covers the two directions of a call:
- Request — the JSON body Sill POSTs to your endpoint, HMAC-signed with the shared secret you minted for the skill.
- Response — the JSON body your endpoint returns, which Sill validates against a canonical schema before passing the (sanitized) data to the agent.
Every schema is a plain JSON Schema (bounded dialect: type, required, properties, additionalProperties, minLength / maxLength, minimum / maximum, enum, items, minItems / maxItems). No $ref, no oneOf, no format — you only need a plain JSON reader to work with them.
The five read skills
Section titled “The five read skills”The five default skills Sill can route to your endpoint are:
| Skill | Purpose |
|---|---|
browse_catalog | Search and browse your product catalog. |
check_availability | Real-time stock lookup for a SKU. |
order_status | Look up an order’s status. Customer-scoped. |
track_shipment | Get shipment tracking for an order. Customer-scoped. |
recommend | Recommend products from your catalog. |
The two transactional default skills (place_order, request_refund) are not part of this contract — they carry a signed payment authorization and settle through Sill’s own payment path.
Customer scoping — your responsibility
Section titled “Customer scoping — your responsibility”The most important rule in this contract sits on two skills: order_status and track_shipment. Sill sends customer_email as a required argument on every call. Your handler MUST verify that the email belongs to the order before returning any data. Sill cannot enforce this for you — only your customer database knows which email owns which order.
If the email does not match, respond one of two ways:
- HTTP 404 with an empty body, or
- HTTP 200 with a canonical result that omits customer-scoped detail (see each skill page for the exact shape).
Do NOT reveal an order to a caller whose email argument does not match. Doing so is the same class of bug as writing your own storefront order-lookup form and forgetting the check — an agent that guesses an order number can then read the order.
The order_status and track_shipment pages open with a concrete correct-vs-wrong example.
For the three non-customer-scoped skills (browse_catalog, check_availability, recommend), Sill does not send customer_email and your handler does not need to scope the response to a specific customer — the data is public catalog information.
What Sill validates for you
Section titled “What Sill validates for you”Sill enforces two invariants on every response before your bytes reach the agent:
- Shape validation. Your response is checked against the canonical response schema for the skill. An extra top-level field (for example,
customer_addresson anorder_statusresponse) is rejected — this stops a well-meaning handler from over-sharing.additionalProperties: falseapplies at every object depth. - Prompt-injection sanitize. The final agent-bound payload is stripped of common prompt-injection markers, secret patterns, XSS markup, and Unicode-tag blocks. If the sanitizer rejects, the agent sees a bounded error rather than your raw bytes.
Beyond those two, Sill does not inspect the semantics of your response. You are the source of truth for order state, catalog content, and inventory.
Verifying Sill’s signature
Section titled “Verifying Sill’s signature”Every request Sill POSTs to your endpoint carries a X-Sill-Signature header:
X-Sill-Signature: t=<unix-seconds>,v1=<hex>tis a Unix timestamp in seconds — the time Sill signed the request.v1is theHMAC-SHA256of the string"<t>.<raw-body>", keyed with the shared secret shown to you when you saved the skill, hex-encoded.
Your handler MUST:
- Read the RAW request bytes (do not re-serialize a parsed JSON — you must sign over the exact bytes received).
- Parse the header, recompute the HMAC, and compare in constant time (
crypto.timingSafeEqual,hmac.compare_digest,hmac.Equal). - Reject stale requests — refuse anything older than ~5 minutes (
abs(now - t) > 300). - On mismatch, return
HTTP 401with an empty body. Do not leak the reason.
If verification passes, parse the body as JSON and dispatch to the skill handler.
TypeScript (Node)
Section titled “TypeScript (Node)”import { createHmac, timingSafeEqual } from 'node:crypto';
const SILL_SECRET = process.env.SILL_SKILL_SECRET as string;const MAX_AGE_SECONDS = 5 * 60;
export function verifySillSignature( header_value: string, raw_body: string, now_seconds = Math.floor(Date.now() / 1000),): boolean { let t = -1; let sig = ''; for (const part of header_value.split(',')) { const eq = part.indexOf('='); if (eq < 0) continue; const k = part.slice(0, eq); const v = part.slice(eq + 1); if (k === 't') t = Number(v); else if (k === 'v1') sig = v; } if (!Number.isFinite(t) || t < 0 || sig.length === 0) return false; if (Math.abs(now_seconds - t) > MAX_AGE_SECONDS) return false;
const expected_hex = createHmac('sha256', SILL_SECRET) .update(`${t}.${raw_body}`) .digest('hex');
const a = Buffer.from(expected_hex, 'hex'); const b = Buffer.from(sig, 'hex'); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
// Express example — capture the RAW body via `express.raw`.import express from 'express';const app = express();
app.post( '/sill/skills', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); const header = req.get('x-sill-signature') ?? ''; if (!verifySillSignature(header, raw)) { res.status(401).send(''); return; } const call = JSON.parse(raw) as { skill_id: string; site_id: string; arguments: Record<string, unknown>; observed_at: string; nonce: string; }; // Route on call.skill_id and return the canonical response shape. },);Python
Section titled “Python”import hmacimport hashlibimport osimport time
from flask import Flask, request, abort
SILL_SECRET = os.environ["SILL_SKILL_SECRET"].encode("utf-8")MAX_AGE_SECONDS = 5 * 60
def verify_sill_signature(header_value: str, raw_body: bytes) -> bool: t = -1 sig = "" for part in header_value.split(","): if "=" not in part: continue k, v = part.split("=", 1) if k == "t": try: t = int(v) except ValueError: return False elif k == "v1": sig = v if t < 0 or not sig: return False if abs(int(time.time()) - t) > MAX_AGE_SECONDS: return False
signed_payload = f"{t}.".encode("utf-8") + raw_body expected = hmac.new(SILL_SECRET, signed_payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)
app = Flask(__name__)
@app.post("/sill/skills")def sill_skills(): raw = request.get_data() header = request.headers.get("X-Sill-Signature", "") if not verify_sill_signature(header, raw): abort(401) call = request.get_json() # Route on call["skill_id"] and return the canonical response shape. return {}package sillfulfill
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
var sillSecret = []byte(os.Getenv("SILL_SKILL_SECRET"))
const maxAgeSeconds int64 = 5 * 60
func VerifySillSignature(headerValue string, rawBody []byte) bool { var t int64 = -1 var sig string for _, part := range strings.Split(headerValue, ",") { eq := strings.IndexByte(part, '=') if eq < 0 { continue } k := part[:eq] v := part[eq+1:] switch k { case "t": n, err := strconv.ParseInt(v, 10, 64) if err != nil { return false } t = n case "v1": sig = v } } if t < 0 || sig == "" { return false } now := time.Now().Unix() diff := now - t if diff < 0 { diff = -diff } if diff > maxAgeSeconds { return false }
mac := hmac.New(sha256.New, sillSecret) mac.Write([]byte(strconv.FormatInt(t, 10))) mac.Write([]byte{'.'}) mac.Write(rawBody) expected := mac.Sum(nil)
got, err := hex.DecodeString(sig) if err != nil { return false } return hmac.Equal(expected, got)}
func SillSkillsHandler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if !VerifySillSignature(r.Header.Get("X-Sill-Signature"), raw) { w.WriteHeader(http.StatusUnauthorized) return } // Route on the parsed body's `skill_id`.}The outbound request body
Section titled “The outbound request body”The body Sill POSTs to your endpoint is a small JSON envelope. It is the same shape for every skill; the per-skill argument shapes live inside arguments:
{ "skill_id": "order_status", "site_id": "01EXAMPLE00000000000000000", "arguments": { "order_id": "#1001", }, "observed_at": "2026-07-05T18:22:15.140Z", "nonce": "01K1EXAMPLE0000000000000000"}skill_id— the canonical snake_case skill identifier.site_id— your site’s ULID.arguments— the per-skill request payload; each skill page documents its schema.observed_at— ISO-8601 UTC of when Sill received the agent’s call.nonce— a unique per-call identifier. Combine withtfrom the signature header to replay-guard.
Your successful response is a JSON body matching the canonical response schema for the skill.
Contract versioning
Section titled “Contract versioning”This is version v1. Additive optional fields on requests or responses are backward-compatible with v1 clients — Sill’s response validator will not reject a body that omits an optional field, and merchant handlers should ignore optional request fields they do not use.
Removals or renames are a v2 change. When a v2 lands, both versions will serve in parallel for at least one release cycle.
See also
Section titled “See also”browse_catalogcheck_availabilityorder_status— customer-scopedtrack_shipment— customer-scopedrecommend- Custom skills — define your own skill beyond the seven defaults, fulfilled the same way.
- Audit envelope — every fulfilled call is written to your signed audit envelope.