Title :
module-contract
Module contract (Hub ↔ Module)
Secretary is split into one hub + many modules. The hub and modules only have to keep the contract (HTTP API) below, and they don’t import each other’s code. Modules can be written in any language.
Client ──HMAC──▶ Hub ──internal token──▶ Module (mail, tapo, sms, kakao, summary ...) ▲ │ └───── event callback ────┘| Task | Hub | Module |
|---|---|---|
| Users, device keys, HMAC auth | O | X |
| Permission (scope) checks, role ceilings | O (based on the manifest) | Declares only |
| Approval queue | O (based on the manifest) | Declares only |
| Audit log | O | Its own server log only |
| Input validation, actual execution, external service keys | X | O |
1. Registering a module
Two lines of hub settings and you’re done. The hub code isn’t touched.
SECRETARY_MODULES=mail=http://secretary-mail:8080,tapo=http://secretary-tapo:8080SECRETARY_MODULE_TOKENS=mail=<random value>,tapo=<random value>- Module names: 2–24 lowercase letters, digits, and hyphens.
system,approvals,modules, andeventsare used by the hub, so they can’t be used. - Create a separate token for each module (
openssl rand -hex 32). Even if one module is compromised, it can’t call other modules.
2. Authentication
- Hub → module:
Authorization: Bearer <that module's token> - Module → hub (events): The same token. The hub figures out which module it is from the token.
- If the token is wrong, the module returns 401. Module ports aren’t opened to the outside (internal Docker network only).
3. APIs a module provides
GET /manifest
The module’s self-introduction. The hub rereads it every minute.
{ "name": "mail", "title": "Mail", "actions": { "send": { "title": "Send email", "approval": true, "roles": null, "params": { "type": "object", "required": ["to", "subject", "body"] } } }}| Field | Meaning |
|---|---|
name |
Must match the name registered with the hub |
actions.<name> |
What it can do. The required scope is automatically <module>:<action> (e.g. mail:send) |
approval |
If true, it isn’t executed right away but goes on the approval queue |
roles |
List of roles allowed to use this action. null means all roles |
params |
Description of the input format (JSON Schema). For client screens. The actual validation is done by the module |
POST /actions/{action}/validate
Checks the input before execution and returns a one-line summary to show on the approval screen.
Request:
{ "params": { "to": ["grandma@family.example"], "subject": "Leaving work", "body": "Heading out now" }, "caller": { "user_id": "dad", "name": "Dad", "role": "admin", "email": "dad@example.com" }}Response: 200 {"summary": "Mail → grandma@family.example : Leaving work"} or 422 {"message": "Some recipients aren't allowed."}
POST /actions/{action}
Actually executes it. The request body is the same as validate. Actions that need approval are called after being approved. The module must check the input once more right before executing (settings may have changed between the request and the approval).
Response: 200 {"result": "Sent"} or 422 {"message": "..."}. Any other status code is treated by the hub as “execution failed.”
GET /healthz
200 {"ok": true}. No authentication. For the Docker health check.
4. Event callback (module → hub)
Things a module needs to report first (a Kakao notification came in, a conversation summary finished) are sent to the hub as events.
POST http://secretary:8080/internal/eventsAuthorization: Bearer <that module's token>
{ "type": "message.received", "user": "dad", "title": "Mom: What's for dinner?" }- If
userisnull, the event is visible to the whole family. titleis up to 200 characters. Don’t include sensitive content like full message bodies.- Clients pick them up with
GET /v1/events. The hub deletes events older than 7 days. /internal/*returns 404 for requests that came through Cloudflare. It can only be used on the internal network.
5. Checklist for building a new module
- Build the 4 APIs above (
/manifest,/actions/{a}/validate,/actions/{a},/healthz). - Put external service keys only in that module’s container.
- Add the service to
docker-compose.ymland attach it to themodulesnetwork (andegresstoo if it needs the internet). - Add
MODULE_TOKEN_<name>to.env, and add one line each to the hub’sSECRETARY_MODULESandSECRETARY_MODULE_TOKENS. - Grant permission to devices:
device add dad phone --scope tapo:onor--scope tapo:*