Write actions
How to add an approval flow to your MDK app
Overview
This guide shows how to expose approval-gated write actions as Gateway routes, then submit, review, approve, reject, or cancel
them from a React app. MDK ships no React hooks for actions: the app calls its own routes with useQuery / useMutation.
Prerequisites
- The Gateway is running with an actions app mounted
- Your actions routes are marked
"auth": trueand the Gateway runs with anauthVerifier, since an unprotected route is reachable by anyone - Your controllers pass the caller's device-family write permissions (
miner:w,container:w) to Kernel asauthPerms, which Kernel requires before resolving or approving a write - If present, your React app is wrapped in
<MdkProvider apiBaseUrl={...}>
Create the actions app first, then call its routes from the UI.
Create an actions app
To enable approval-gated writes, create an app that exposes HTTP routes for the write-action workflow. Each route calls the
corresponding method on req.mdk, the request's Kernel client. For a signed-in user it acts for that user: the Kernel verifies
their token, checks both the app's grant and the user's own spec.rbac rules, and records the user as the voter.
The paths shown below (/auth/actions*) are illustrative examples. You may use any path structure that fits your app's routing pattern.
Required routes
| Method | Example Path | req.mdk method | Purpose |
|---|---|---|---|
GET | /auth/actions | queryActions() | Query actions by lifecycle state (voting/ready/executing/done) |
POST | /auth/actions/voting | pushAction() | Submit a single action for approval |
POST | /auth/actions/voting/batch | pushActionsBatch() | Submit multiple actions for approval |
PUT | /auth/actions/voting/:id/vote | voteAction() | Cast approval/rejection vote on an action |
DELETE | /auth/actions/voting/cancel | cancelActionsBatch() | Cancel pending actions by IDs |
App structure
Start from mdk create app actions and replace its summary route with one controller per route:
apps/actions/
├── mdk-app.yaml
└── plugin/
├── mdk-plugin.json
└── controllers/
├── query.js
├── push.js
├── push-batch.js
├── vote.js
└── cancel.jsExample controller (push.js)
'use strict'
module.exports = async function pushAction (req) {
// req.mdk acts for the user the Gateway verified: Kernel checks their rules and the app's
return await req.mdk.pushAction({
query: req.body.query, // Device query/selector
action: req.body.action, // Action name from worker contract
params: req.body.params, // Action parameters
voter: req._info.sub, // Required by the payload; Kernel records the verified user
authPerms: [] // Required by the payload; spec.rbac decides
})
}Manifest example (mdk-plugin.json)
{
"name": "actions",
"version": "1.0.0",
"description": "Approval-gated write action APIs",
"routes": [
{
"id": "actions.push",
"handler": "./controllers/push.js",
"auth": true,
"http": {
"method": "POST",
"path": "/auth/actions/voting",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["query", "action", "params"],
"properties": {
"query": { "type": "object" },
"action": { "type": "string" },
"params": { "type": "array" }
}
}
}
}
}
},
"description": "Submit a write action for approval",
"safety": "Stage-only: does not execute until approved"
}
]
}"auth": true makes the Gateway answer 401 unless the authVerifier vouches for the caller, which matters more here than on a read route
because it stages fleet-changing writes. The controller still checks that the identity in req._info holds the write permission.
Auth and permissions covers the patterns.
Mount the app
Declare it in mdk.yaml and start the stack with mdk run. The React hooks call /auth/actions* at the Gateway root, so mount
the app at / rather than the default /apps/<name>:
spec:
apps:
- name: actions
package: actions
mount: /Running it from your own code instead goes through startGateway({ apps }) and createAppRuntime(), as
starting apps from code shows.
Calls through config.mdkClient act as the app alone: the Kernel checks only the app's grant and records the app as the voter.
Use req.mdk for anything a user asked for, so their own devices: write and actions rules gate the write.
Call the routes from a React app
Read the queue with useQuery and send writes with useMutation, both re-exported by
@tetherto/mdk-react-adapter. Request through mdkFetch, which puts the signed-in user's access token on
every call and parses the JSON body: a bare fetch sends no token, and the route answers 401. Invalidate the queue after
each write so the list refreshes:
import { useMdkContext, useMutation, useQuery, useQueryClient } from "@tetherto/mdk-react-adapter";
import { mdkFetch } from "@tetherto/mdk-ui-foundation";
const QUEUE_KEY = ["actions", "voting"];
type PendingAction = { id: string };
function PendingActions() {
const { apiBaseUrl } = useMdkContext();
const queryClient = useQueryClient();
const { data: pending = [] } = useQuery({
queryKey: QUEUE_KEY,
// Your GET route decides how it maps the request onto `queryActions()` and what it returns;
// this example assumes it returns the voting actions as an array. `mdkFetch` parses the body
// and returns it as the type you give it, so name that type or `pending` arrives as `unknown`.
queryFn: () => mdkFetch<PendingAction[]>(`${apiBaseUrl}/auth/actions`),
refetchInterval: 10_000,
});
const vote = useMutation({
mutationFn: ({ id, approve }: { id: string; approve: boolean }) =>
mdkFetch(`${apiBaseUrl}/auth/actions/voting/${id}/vote`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ approve }),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEUE_KEY }),
});
return pending.map((action) => (
<button key={action.id} type="button" onClick={() => vote.mutate({ id: action.id, approve: true })}>
Approve {action.id}
</button>
));
}Submit (POST /auth/actions/voting) and cancel (DELETE /auth/actions/voting/cancel) follow the same useMutation shape.
Approved actions become command requests once the vote thresholds are met; the item then leaves the voting queue.
For complete mdk-client method signatures and protocol details, see the mdk-client README and Kernel actions integration tests.
Next steps
- Understand the approval-gated write architecture — including how approved actions become normal command requests
- Protect your routes with controller-level auth and permission checks
- Build routes with the app route table format, including caching and manifest fields
- Run integration coverage:
backend/core/kernel/tests/integration/actions.test.js