Typestate
Sign in

Requests and approvals

People ask for a purchase, an expense or leave. An approver says yes or no, and the team hears of it on Slack.

For a team of 5 to 50 with purchase, expense or leave requests.

Slack messages logo Slack messagesResend logo Resend
Start it with your agentStarting from a blueprint does not count against the action changes of your plan.

Start the requests and approvals blueprint on Typestate

Asked you for the Slack channel of your approvers and the address your emails come from.

Started Requests and approvals: 2 record types, 6 web endpoints.

7 actions are compiling.

Deployed to Sandbox. Try it at your Sandbox address.

What your agent does when you start this blueprint

What runs

Each step has 3 parts: who starts it, how it reaches your backend, and the action that answers.

  1. A team memberasks for a purchase, an expense or leave
    Web endpointPOST /requests
    Submit a RequestSaves the request and posts it on Slack.saves a Requestposts 1 Slack message
  2. An approverreads what waits for a decision
    Web endpointGET /requests/waiting
    List Requests to ApproveAnswers the waiting requests, oldest first.reads only
  3. An approversays yes, and the person who asked gets an email
    Web endpointPOST /requests/approve
    Approve a RequestApproves the request and emails the person who asked.changes a Requestsaves a Decisionsends 1 email
  4. An approveror says no, with the reason
    Web endpointPOST /requests/reject
    Reject a RequestRejects the request and emails the reason.changes a Requestsaves a Decisionsends 1 email
  5. An approvermarks an approved expense paid
    Web endpointPOST /requests/paid
    Mark a Request PaidMarks it paid and emails the person who asked.changes a Requestsaves a Decisionsends 1 email
  6. Your backendposts requests that wait more than 2 days, each morning
    TimerEvery weekday at 09:00, Berlin time
    Nudge Waiting RequestsPosts the count of late requests on Slack.posts 1 Slack message
  • Web endpoint: your website or app calls it
  • Timer: it runs at a set time

The rule you will want to change first

Nobody decides their own request, so a second person looks at every one.

It lives in the action Approve a Request. Tell your agent how you want it instead: one sentence changes, and you read the change before it goes Live.

What it is made of

The map shows the records it keeps, the actions it runs and the services it calls.

What it keeps

Requestperson, email, kind, title, amount, details, first day, last day, state
Decisionrequest, decision, decided by, note

What it does

Submit a Requestweb endpoint
Approve a Requestweb endpointThe rule to change first
Reject a Requestweb endpoint
Mark a Request Paidweb endpoint
List My Requestsweb endpoint
List Requests to Approveweb endpoint
Nudge Waiting RequestsEvery weekday at 09:00, Berlin time

What it reaches

Slack messagesPost to a Slack channel, and act on what people write there.
ResendSend email from your own address: sign-in links, receipts, reminders.
Your website6 web endpoints

What it keeps

Request

9 fields

One request, with who asked, what for, and where it stands.

person
required
email
required, an email address
kind
required, one of purchase, expense, leave
title
required, at most 200 characters
amount
optional
details
optional, at most 4000 characters
first day
optional, a date
last day
optional, a date
state
required, one of waiting, approved, rejected, paid

A team member sees only their own requests. An approver sees and changes all of them.

Decision

4 fields

Each decision on a request: who made it, and why.

request
required
decision
required, one of approved, rejected, paid
decided by
required, an email address
note
optional, at most 2000 characters

Decisions are kept and never changed.

What it does

Open an action to read its steps: each sentence, with the calls that do it under it.

Submit a RequestA team member asks for a purchase, an expense or leave, and the approvers hear of it on Slack.

Who starts it

From the webPOST /requests

What it is given

Kindone of purchase, expense, leave, needed

Titletext, needed

AmountMoney, optional

Detailstext, optional

First daya date, optional

Last daya date, optional

What it does

  • input.kindwhat the caller gives
  • requesta value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • "leave"a fixed text or number
  • caller.emailwhat it knows without being told
  • channela part of an email or a payment
  • Refusewhere it says no

When the kind is leave and no first day was given, refuse with "Name the first day of the leave".

When input.kind is "leave"

and input.first_day is not given

Refuse "Name the first day of the leave"

When the kind is purchase or expense and no amount was given, refuse with "Name the amount".

When input.kind is one of "purchase" or "expense"

and input.amount is not given

Refuse "Name the amount"

Create a Request with person_id set to the signed-in person, email set to the address they signed in with, the given kind, title, amount, details, first day and last day, and state set to waiting.

Add a Request

set amount to input.amount

set details to input.details

set email to caller.email

set first_day to input.first_day

set kind to input.kind

set last_day to input.last_day

set person_id to caller.id

set state to "waiting"

set title to input.title

Post the request in the approvers' Slack channel, with its kind, its title and the address of the person who asked.

Ask Slack to Post Message

channel is the setting SLACK_CHANNEL

text is "New input.kind request from caller.email: input.title"

Answer with that Request's id and state.

Answer

id is request.id

state is request.state

Approve a RequestAn approver says yes to a request, and the person who asked gets an email.

Who starts it

From the webPOST /requests/approve

What it is given

Request IDtext, needed

Notetext, optional

What it does

  • input.request_idwhat the caller gives
  • requesta value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • "approver"a fixed text or number
  • caller.rolewhat it knows without being told
  • toa part of an email or a payment
  • Refusewhere it says no

When the signed-in person is not an approver, refuse with "Only an approver decides".

When not (caller.role is "approver")

Refuse "Only an approver decides"

Find the Request whose id is the given request id.When none matches, refuse with "No such request".

request is

Find the first Request where

id is input.request_id

when none refuse "No such request"

When that Request's state is not waiting, refuse with "This request is already decided".

When request.state is not "waiting"

Refuse "This request is already decided"

When that Request is the signed-in person's own, refuse with "Nobody decides their own request".

A second person looks at every request, so nobody approves their own spending.

When request.person_id is caller.id

Refuse "Nobody decides their own request"

Change that Request's state to approved.

Change the Request where

id is request.id

set state to "approved"

Create a Decision with request_id set to that Request's id, decision set to approved, decided_by set to the signed-in person's address, and note set to the given note.

Add a Decision

set decided_by to caller.email

set decision to "approved"

set note to input.note

set request_id to request.id

Email that Request's address with the subject "Your request was approved", naming its title and the note.

Send an email

to request.email

template "Approve a Request Email"

note the given note, or else ""

title request.title

Answer with that Request's id and the state approved.

Answer

id is request.id

state is "approved"

Reject a RequestAn approver says no to a request, with the reason, and the person who asked gets an email.

Who starts it

From the webPOST /requests/reject

What it is given

Request IDtext, needed

Notetext, needed

What it does

  • input.request_idwhat the caller gives
  • requesta value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • "approver"a fixed text or number
  • caller.rolewhat it knows without being told
  • toa part of an email or a payment
  • Refusewhere it says no

When the signed-in person is not an approver, refuse with "Only an approver decides".

When not (caller.role is "approver")

Refuse "Only an approver decides"

Find the Request whose id is the given request id.When none matches, refuse with "No such request".

request is

Find the first Request where

id is input.request_id

when none refuse "No such request"

When that Request's state is not waiting, refuse with "This request is already decided".

When request.state is not "waiting"

Refuse "This request is already decided"

When that Request is the signed-in person's own, refuse with "Nobody decides their own request".

A second person looks at every request, so nobody approves their own spending.

When request.person_id is caller.id

Refuse "Nobody decides their own request"

Change that Request's state to rejected.

Change the Request where

id is request.id

set state to "rejected"

Create a Decision with request_id set to that Request's id, decision set to rejected, decided_by set to the signed-in person's address, and note set to the given note.

Add a Decision

set decided_by to caller.email

set decision to "rejected"

set note to input.note

set request_id to request.id

Email that Request's address with the subject "Your request was not approved", naming its title and the note.

Send an email

to request.email

template "Reject a Request Email"

note the given note, or else ""

title request.title

Answer with that Request's id and the state rejected.

Answer

id is request.id

state is "rejected"

Mark a Request PaidAn approver records that an approved purchase or expense was paid out.

Who starts it

From the webPOST /requests/paid

What it is given

Request IDtext, needed

What it does

  • input.request_idwhat the caller gives
  • requesta value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • "approver"a fixed text or number
  • caller.rolewhat it knows without being told
  • toa part of an email or a payment
  • Refusewhere it says no

When the signed-in person is not an approver, refuse with "Only an approver decides".

When not (caller.role is "approver")

Refuse "Only an approver decides"

Find the Request whose id is the given request id.When none matches, refuse with "No such request".

request is

Find the first Request where

id is input.request_id

when none refuse "No such request"

When that Request is leave, or its state is not approved, refuse with "Only an approved purchase or expense is paid".

When request.kind is "leave"

or request.state is not "approved"

Refuse "Only an approved purchase or expense is paid"

Change that Request's state to paid.

Change the Request where

id is request.id

set state to "paid"

Create a Decision with request_id set to that Request's id, decision set to paid, and decided_by set to the signed-in person's address.

Add a Decision

set decided_by to caller.email

set decision to "paid"

set request_id to request.id

Email that Request's address with the subject "Your request was paid", naming its title and its amount, or no amount when the Request has none.

Send an email

to request.email

template "Mark a Request Paid Email"

amount the request's amount as the text of money when the request's amount holds a value, otherwise "no amount"

title request.title

Answer with that Request's id and the state paid.

Answer

id is request.id

state is "paid"

List My RequestsA team member reads their own requests, newest first, with where each one stands.

Who starts it

From the webGET /requests/mine

What it is given

Nothing. It needs no input.

What it does

  • owna value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • caller.idwhat it knows without being told

Find up to 100 Requests whose person_id is the signed-in person, newest first.

own is

Find Requests where

person_id is caller.id

sorted by created_at, highest first

Answer with each Request's id, kind, title, amount, first and last day, state, email and the time it was asked.

Answer

requests is

each request of own as

amount is request.amount

asked_at is request.created_at

email is request.email

first_day is request.first_day

id is request.id

kind is request.kind

last_day is request.last_day

state is request.state

title is request.title

List Requests to ApproveAn approver reads the requests that wait for a decision, oldest first.

Who starts it

From the webGET /requests/waiting

What it is given

Nothing. It needs no input.

What it does

  • waitinga value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • "approver"a fixed text or number
  • caller.rolewhat it knows without being told
  • Refusewhere it says no

When the signed-in person is not an approver, refuse with "Only an approver decides".

When not (caller.role is "approver")

Refuse "Only an approver decides"

Find up to 100 Requests whose state is waiting, oldest first.

waiting is

Find Requests where

state is "waiting"

sorted by created_at, lowest first

Answer with each Request's id, kind, title, amount, first and last day, state, email and the time it was asked.

Answer

requests is

each request of waiting as

amount is request.amount

asked_at is request.created_at

email is request.email

first_day is request.first_day

id is request.id

kind is request.kind

last_day is request.last_day

state is request.state

title is request.title

Nudge Waiting RequestsEach morning, requests that waited more than 2 days are posted in the approvers' Slack channel again.

Who starts it

A timer, every weekday at 09:00, Berlin time

What it is given

Nothing. It needs no input.

What it does

  • latea value it works out
  • Requesta record type
  • Requestan attribute of a record or an answer
  • 48 hoursa fixed text or number
  • nowwhat it knows without being told
  • channela part of an email or a payment

Count the Requests whose state is waiting and that were asked more than 48 hours ago.When there is none, stop.

late is

Count Requests where

created_at is before now - 48 hours

state is "waiting"

When late is 0

Answer

Post in the approvers' Slack channel that this many requests from the signed-in person wait more than 2 days for a decision.

Ask Slack to Post Message

channel is the setting SLACK_CHANNEL

text is "late as a number requests from caller.email wait more than 2 days for a decision."

What it does by itself

  • Every weekday at 09:00, Berlin time, requests that wait more than 2 days are posted again.

Who signs in and what they may read

People sign up and sign in with an email address and a password, or with a provider you add, such as Google. The roles are approver, and the first person to sign in is the approver.

  • Request: A team member sees only their own requests. An approver sees and changes all of them.
  • Decision: Decisions are kept and never changed.

Web endpoints

What it doesWho may callAddress
A signed-in team member asks.a signed-in personPOST /requests
A team member reads their own requests.a signed-in personGET /requests/mine
An approver reads what waits.a signed-in personGET /requests/waiting
An approver says yes.a signed-in personPOST /requests/approve
An approver says no.a signed-in personPOST /requests/reject
An approver marks a request paid.a signed-in personPOST /requests/paid

What it cannot do yet

  • It does not keep receipts or other files with a request.
  • It does not pay anybody. An approver pays as the team always does, and then marks the request paid.
  • It knows one approver role for the whole team. Who approves what amount is not set.

Build the pages with your tool

Typestate is the backend. Paste this brief into Lovable, v0, Claude Code or the tool that builds your pages.

Show the brief
Build the pages for "Requests and approvals". The backend runs on Typestate and answers these web endpoints: POST /requests: A signed-in team member asks. Who may call: a signed-in person. GET /requests/mine: A team member reads their own requests. Who may call: a signed-in person. GET /requests/waiting: An approver reads what waits. Who may call: a signed-in person. POST /requests/approve: An approver says yes. Who may call: a signed-in person. POST /requests/reject: An approver says no. Who may call: a signed-in person. POST /requests/paid: An approver marks a request paid. Who may call: a signed-in person. People sign up at POST /auth/signup and sign in at POST /auth/login, which answers a token. Send it as Authorization: Bearer <token>. A refused call answers a sentence that can be shown to the person as it is.

Start it

  1. Tell your agent the sentence on the right.
  2. It asks you for the Slack channel where approvers hear of new requests, such as #approvals. Invite your Slack app to it.
  3. It asks you for the address emails come from, on a domain you verified at Resend, such as "Riverside Physio <hello@riverside.example>".
  4. It tells you what only you can do, such as pasting your Resend key in the cockpit. A key never goes through the agent.
  5. Open your Sandbox address and try it yourself.

Starting from this blueprint does not count against the action changes of your plan. The Free plan runs it on Sandbox. Live needs a paid plan.

Start the requests and approvals blueprint on TypestateChoose a plan

Related blueprints