Organizing your dashboard
- TypeScript
- JavaScript
- Python Experimental
We now having a working Interval action! In this section we'll add additional structure and organization to our Interval dashboard, adding some hierarchical navigation and grouping related actions within a page.
info
The Pages API is available on SDK versions v0.35.0 and higher.
Pages are still in beta. The API names and functionality may change.
Creating a page
By default all the Interval actions you create will live at the top-level in your dashboard. We provide ways to filter and search for the tool you're looking for, but you'll probably want to create pages to better organize your actions however you see fit.
To create a page, define and pass in a Page
class in when you instantiate Interval
.
import Interval, { Page, io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import { getCharges, refundCharge } from "./payments";
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
}),
refund_user: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
Pages may be passed in under routes
alongside any actions you want to create. The provided slug configures the URL path for the page, just as it does with actions, and must be unique at that level of navigation.
To create a page, define and pass in a Page
class in when you instantiate Interval
.
const { Interval, Page, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge } = require("./payments");
require("dotenv").config(); // loads environment variables from .env
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
}),
refund_user: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
Pages may be passed in under routes
alongside any actions you want to create. The provided slug configures the URL path for the page, just as it does with actions, and must be unique at that level of navigation.
To create a page, define a Page
class and assign a path for it by adding it to your routes. The provided slug configures the URL path for the page, just as it does with actions, and must be unique at that level of navigation.
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@interval.action
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()
Once you've defined your page, if you revisit the Interval dashboard, you'll now see top-level navigation for reaching your page.

The created page can also act as a "sub-folder" for grouping actions. Simply pass actions in to the page's routes
property, just as you would do at the top level. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
import Interval, { Page, io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import { getCharges, refundCharge } from "./payments";
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
routes: {
refund_user: {
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
The created page can also act as a "sub-folder" for grouping actions. Simply pass actions in to the page's routes
property, just as you would do at the top level. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
const { Interval, Page, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge } = require("./payments");
require("dotenv").config(); // loads environment variables from .env
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
routes: {
refund_user: {
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
The created page can also act as a "sub-folder" for grouping actions. Simply use the @page.action
decorator instead of @interval.action
. Here we'll move our refund tool to our new "Refunds" page, and also provide an explicit name
for a cleaner display:
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.action(name="Refund user")
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `items_in_queue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()

You can repeat this process to add as many pages as you like to your dashboard, grouping related tools and information together on separate pages to help organize your dashboard and make it easier to navigate. Pages can also be nested arbitrarily!
Rendering content
Currently our page simply acts as a sub-folder for grouping actions. You can also add content to each page to provide more information and context for the tools and data on that page. This could include text descriptions, metadata cards, tables, images, code snippets, and any other content supported via Interval display
I/O methods.
We'll flesh out our new page with a description, stats on the number of recent refunds, and a table listing the refunds we've made.
To accomplish this, we'll define a handler
function when instantiating the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
import Interval, { Page, io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import { getCharges, refundCharge, getRefunds } from "./payments";
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
routes: {
refund_user: {
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
To accomplish this, we'll define a handler
function when instantiating the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
const { Interval, Page, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge, getRefunds } = require("./payments");
require("dotenv").config(); // loads environment variables from .env
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
routes: {
refund_user: {
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
To accomplish this, we'll define a handler function using the handle
decorator on the Page
class. This function should load any required data and return a Layout
class that defines the content of the page.
import os
from interval_sdk import Interval, IO, ctx_var, Page, Layout
from payments import get_charges, refund_charge, get_refunds
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.handle(name="Refund user")
async def handler(display: IO.Display):
refunds = await get_refunds()
return Layout(
title="Refunds",
description="View and create refunds for our customers.",
children=[
display.metadata(
"",
layout="card",
data=[
{"label": "Total refunds", "value": len(refunds)},
],
),
display.table("Refunds", data=refunds),
],
)
@refunds_page.action(name="Refund user"
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()
Here's the update to payments.ts
with the new get_refunds
function we're calling above.
import time
import random
import string
import asyncio
from datetime import datetime
charges = {}
refunds = []
async def get_customer_charges(customer_email):
if not customer_email:
raise Exception("A customer id is required")
if customer_email not in charges:
charges[customer_email] = [
create_charge(customer_email) for i in range(random.randint(1, 5))
]
# Replaces Stripe SDK call
# customer = stripe.Customer.list(limit=1, email=customer_email)["data"][0]
# return stripe.Charge.list(customer=customer.id)
customer_charges = charges[customer_email]
return [format_charge(charge) for charge in customer_charges]
async def refund_charge(charge_id):
await asyncio.sleep(1)
if not charge_id:
raise Exception("A charge id is required")
charge = next(
charge
for customer_id in charges
for charge in charges[customer_id]
if charge["id"] == charge_id
)
if not charge:
raise Exception("Charge not found")
if charge["refunded"]:
raise Exception("Charge already refunded")
refund = {
"id": f"ch_{random_string()}",
"amount": charge["amount"],
"charge": charge["id"],
"currency": charge["currency"],
"created": round(time.time()),
}
charge_index = next(
i
for i in range(len(charges[charge["customer"]]))
if charges[charge["customer"]][i]["id"] == charge_id
)
# Replaces Stripe SDK call
# stripe.Refund.create(charge=charge_id)
charge["refunded"] = True
charges[charge["customer"]][charge_index] = charge
refunds.append(refund)
return refund
def create_charge(customer_id):
return {
"id": f"ch_{random_string()}",
"amount": random.randint(100, 10000),
"currency": "usd",
"description": random_string(),
"created": round(time.time()),
"customer": customer_id,
"refunded": False,
}
def format_charge(charge):
return {
"id": charge["id"],
"amount": "${:,.2f}".format(charge["amount"] / 100),
"description": charge["description"],
"created": datetime.utcfromtimestamp(charge["created"]).strftime(
"%Y-%m-%d %H:%M:%S"
),
"isRefunded": charge["refunded"],
}
async def get_refunds():
# Replaces Stripe SDK call
# return stripe.Refund.list()
return [format_refund(refund) for refund in refunds]
def format_refund(refund):
return {
"id": refund["id"],
"charge": refund["charge"],
"amount": "${:,.2f}".format(refund["amount"] / 100),
"currency": refund["currency"],
"created": datetime.utcfromtimestamp(refund["created"]).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
def random_string(length=10):
return "".join(
random.choice(string.ascii_lowercase + string.digits) for i in range(length)
)

Adding menu items
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menuItems
property.
menuItems
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menuItems
elements have the same signature as Interval links).
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
import Interval, { Page, io, ctx } from "@interval/sdk";
import "dotenv/config"; // loads environment variables from .env
import { getCharges, refundCharge, getRefunds } from "./payments";
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds()
return new Layout({
title: 'Refunds',
description: 'View and create refunds for our customers.',
menuItems: [
{
label: 'Create refund',
route: 'refunds/refund_user',
},
],
children: [
io.display.metadata('', {
layout: 'card',
data: [
{
label: 'Total refunds',
value: refunds.length,
},
],
}),
io.display.table('Refunds', {
data: refunds,
}),
],
})
},
routes: {
refund_user: {
unlisted: true,
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menuItems
property.
menuItems
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menuItems
elements have the same signature as Interval links).
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
const { Interval, Page, io, ctx } = require("@interval/sdk");
const { getCharges, refundCharge, getRefunds } = require("./payments");
require("dotenv").config(); // loads environment variables from .env
const interval = new Interval({
apiKey: process.env.INTERVAL_KEY,
routes: {
refunds: new Page({
name: "Refunds",
handler: async () => {
const refunds = await getRefunds();
return new Layout({
title: "Refunds",
description: "View and create refunds for our customers.",
menuItems: [
{
label: "Create refund",
route: "refunds/refund_user",
},
],
children: [
io.display.metadata("", {
layout: "card",
data: [
{
label: "Total refunds",
value: refunds.length,
},
],
}),
io.display.table("Refunds", {
data: refunds,
}),
],
});
},
routes: {
refund_user: {
unlisted: true,
name: "Create refund",
handler: async () => {
const customerEmail = await io.input.email(
"Email of the customer to refund:"
);
console.log("Email:", customerEmail);
const charges = await getCharges(customerEmail);
const chargesToRefund = await io.select.table(
"Select one or more charges to refund",
{
data: charges,
}
);
await ctx.loading.start({
title: "Refunding charges",
// Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
itemsInQueue: chargesToRefund.length,
});
for (const charge of chargesToRefund) {
await refundCharge(charge.id);
await ctx.loading.completeOne();
}
// Values returned from actions are automatically stored with Interval transaction logs
return { chargesRefunded: chargesToRefund.length };
},
},
},
}),
},
});
// Establishes a persistent connection between Interval and your app.
interval.listen();
While actions nested within a page provide organizational structure within your dashboard, you can also customize the UI of pages to provide quick access to useful actions via the menu_items
property.
menu_items
add easy-to-access buttons at the top of any page that can link to any actions within your Interval dashboard or to external sites (menu_items
elements have the same signature as Interval links).
For actions that are set as menu items, you can avoid showing a duplicate link in the page sidebar by setting the unlisted
property on the action definition.
import os
from interval_sdk import Interval, IO, ctx_var, Page
from payments import get_charges, refund_charge, get_refunds
interval = Interval(
os.environ.get("INTERVAL_KEY"),
)
refunds_page = Page(name="Refunds")
interval.routes.add("refunds", refunds_page)
@refunds_page.handle(name="Refund user", unlisted=True)
async def handler(display: IO.Display):
refunds = await get_refunds()
return Layout(
title="Refunds",
description="View and create refunds for our customers.",
menu_items=[
{"label": "Create refund", "route": "refunds/refund_user"},
],
children=[
display.metadata(
"",
layout="card",
data=[
{"label": "Total refunds", "value": len(refunds)},
],
),
display.table("Refunds", data=refunds),
],
)
@refunds_page.action(name="Refund user"
async def refund_user(io: IO):
customer_email = await io.input.email("Email of the customer to refund:")
print("Email:", customer_email)
charges = await get_charges(customer_email)
charges_to_refund = await io.select.table(
"Select one or more charges to refund",
data=charges,
)
ctx = ctx_var.get()
await ctx.loading.start(
title="Refunding charges",
# Because we specified `itemsInQueue`, Interval will render a progress bar versus an indeterminate loading indicator.
items_in_queue=len(charges_to_refund),
)
for charge in charges_to_refund:
await refund_charge(charge.id)
await ctx.loading.complete_one()
# Values returned from actions are automatically stored with Interval transaction logs
return { "charges_to_refund": len(charges_to_refund) }
# Establishes a persistent connection between Interval and your app.
interval.listen()

✅ Recap
In Part 2, we extended our Interval dashboard by creating our first page to group actions and render additional content.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested within a page's
routes
property or linked to viamenuItems
.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested within a page's
routes
property or linked to viamenuItems
.
- Pages can be created by instantiating the
Page
class alongside actions. - To render content on a page, define the
handler
function and utilize I/Odisplay
methods. - Actions can be nested within a page's
routes
property or linked to viamenu_items
.