Building a Tawa PlugIN

Scaffold

tawa init my-plugin        # full scaffold
tawa sample --api my-plugin  # alternative

Checklist

Infrastructure:
  [ ] catalog-info.yaml with routes, gas, databases, internalDependencies
  [ ] GET /health returning { status: 'ok', service: 'my-plugin' }
  [ ] tawa preflight passes

Code:
  [ ] Zod validation on all POST endpoints
  [ ] Septor emit on all financial/compliance operations (fire-and-forget)
  [ ] No console.log — use pino logger
  [ ] No hardcoded secrets — use process.env
  [ ] RelayClient.fromEnv() not new RelayClient({ jwtSecret })
  [ ] verifyTokenJWKS() not verifyToken(token, secret)

Testing:
  [ ] 80%+ test coverage
  [ ] Tests verify Septor events emitted correctly

Documentation:
  [ ] openapi.yaml for auto-generated UI tile
  [ ] CHANGELOG.md (builder publishes on deploy automatically)

Minimal catalog-info.yaml

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: my-plugin
  description: What your plugin does
  annotations:
    insureco.io/framework: express
    insureco.io/pod-tier: nano
    insureco.io/openapi: openapi.yaml      # enables auto-generated UI tile
    insureco.io/catalog-version: "0.5.0"
spec:
  type: service
  lifecycle: production
  owner: my-org

  routes:
    - path: /api/my-plugin/action
      methods: [POST]
      auth: required
      gas: 5           # tokens charged per successful call

  databases:
    - type: mongodb

  internalDependencies:
    - service: septor
      port: 3000

  tests:
    smoke:
      - path: /health
        expect: 200

Required: Health Endpoint

app.get('/health', (_req, res) => {
  res.json({ status: 'ok', service: 'my-plugin' })
})

Required: Input Validation

import { z } from 'zod'

const RequestSchema = z.object({
  entityName: z.string().min(1).max(200),
  orgSlug: z.string().min(1),
})

app.post('/api/my-plugin/action', async (req, res) => {
  const parsed = RequestSchema.safeParse(req.body)
  if (!parsed.success) {
    return res.status(400).json({ success: false, error: parsed.error.message })
  }
  const { entityName, orgSlug } = parsed.data
  // ...
})

Required: Septor Audit

septor.emit('action.completed', {
  entityId: orgSlug,
  data: { entityName, result: result.status },
  metadata: { who: req.user?.bioId, why: 'Plugin action requested' },
}).catch((err) => logger.error({ err }, 'Septor emit failed'))

Gas Is Automatic

Gas is charged by Janus for routes declared with gas. You write no gas code. The platform handles it.

Key Facts

  • gas: 0 = free route (good for status/health checks)
  • auth: required always on financial/compliance routes
  • Missing /health = deploy health check fails immediately
  • Missing internalDependencies = env vars like SEPTOR_URL not injected
  • CHANGELOG.md in your repo is published automatically on every deploy

Last updated: February 28, 2026