Tutorial
Create a frontend Pine extension from scratch.
Extensions are sandboxed, client-side applications that extend Pine's capabilities. Extensions can be used to display information from third-party tools directly within the Pine interface.
Pine's platform supports standard web technologies, meaning that creating extensions only requires knowledge of JavaScript, HTML, and CSS. The only limitation is that all of your code must be self-contained and bundled into a single index.html
file.
In this tutorial, we'll create a Pine extension from scratch. The extension will feature multiple routes and will simply display the ID of the currently selected card.

Before getting started, please ensure that you meet the following prerequisites:
You have the latest stable version Node.js installed.
You have the latest stable version of NPM installed.
You are familiar with React and TypeScript.
Create a TypeScript app with Vite
Using your terminal, run the following to create your project:
npm create vite@latest pine-extension-tutorial -- --template react-ts
Once complete, navigate to your project and install the dependencies for two additional libraries:
cd pine-extension-tutorial
npm install @pinecards/client @radix-ui/themes
@pinecards/client
contains logic for communicating with Pine via IPC messaging.@radix-ui/themes
provides fully-styled components for building the application (Pine uses this library internally, so this will ensure consistency with the main interface).
To start with a clean slate, let's delete the existing App.css
file and replace the contents of the App.tsx
file with the following:
function App() {
return <div>todo</div>;
}
export default App;
We'll also replace the contents of the index.css
file with just the following:
html {
color-scheme: normal !important;
}
body {
margin: 0;
}
.radix-themes {
background: transparent;
}
You can then start your application in development using:
npm run dev
Coordinate app state with @pinecards/client
@pinecards/client
At the top of your App.tsx
file, import the following:
import { connectSDKClient, Theme, Route } from "@pinecards/client";
import { useCallback, useEffect, useState } from "react";
We'll want to execute connectSDKClient
as soon as possible in the global scope of the application. Calling this function will create a singleton client
that we can use to communicate with Pine.
const client = connectSDKClient();
Next, we'll define what our application state should look like:
interface AppState {
path: "overview" | "card";
theme: Theme;
result: string | null;
}
The
path
will be used to navigate between our extension's routes.The
theme
property will be used to keep the application theme in sync with the theme data received from Pine.The
result
property will be used to store the card identifier that we extract whenever Pine changes routes.
We'll hook this up inside of the App
component by using the previously imported useState
hook to define the state and the useCallback
hook to allow for partial state updates:
function App() {
const [state, setState] = useState<AppState>({
path: "overview",
theme: { mode: "light", color: "gold" },
result: null,
});
const onStateChange = useCallback((partialState: Partial<AppState>) => {
setState((state) => ({ ...state, ...partialState }));
}, []);
return <div>todo</div>;
}
Next, we'll create a useEffect
block and use the client
to communicate with Pine to get the current theme data and listen for future changes.
useEffect(() => {
client.context
.getTheme()
.then((theme) => onStateChange({ theme }))
.catch(() => {
// handle error
});
const promise = client.context.on("themeChange", (event) => {
onStateChange({ theme: event.data });
});
return () => {
promise.then((unsubscribe) => unsubscribe());
};
}, [onStateChange]);
We'll create a separate useEffect
block to listen for route changes.
Instead of returning a URL that needs to be manually parsed, Pine returns the path
template and the params
that were used to populate that template.
We can take advantage of this by implementing a switch
statement that handles paths that involve card identifiers, and assign the route.params.id
to our result
.
useEffect(() => {
const promise = client.routes.on("routeChange", async (event) => {
const route = event.data;
switch (route.path) {
case "/dates/:dateId/cards/:id":
case "/decks/:deckId/cards/:id":
case "/home/cards/:id":
case "/inbox/cards/:id":
case "/review/cards/:id": {
onStateChange({ result: route.params.id });
break;
}
default:
onStateChange({ result: null });
}
});
return () => {
promise.then((unsubscribe) => unsubscribe());
};
}, [onStateChange]);
Compose the interface with @radix-ui/themes
@radix-ui/themes
Let's import the Radix CSS file and the necessary components that we'll need:
import "@radix-ui/themes/styles.css";
import {
Button,
Callout,
Flex,
Theme as ThemeProvider,
} from "@radix-ui/themes";
Next, we'll render the ThemeProvider
and pass through the following properties:
scaling
, which we'll set to a value of"90%"
to ensure that the default Radix components are appropriately scaled to the rest of the interface.appearance
, which will be assignedstate.theme.mode
to keep the user's theme preferences in sync with the extensions.accentColor
, which will be assignedstate.theme.color
to keep the user's color preferences in sync with the extension.
return (
<ThemeProvider
scaling="90%"
appearance={state.theme.mode}
accentColor={state.theme.color}
>
</ThemeProvider>
);
From this point, you can freely use the components described in Radix's website. For example, we'll add a Button
for navigating between routes and we'll use the Callout
component to display the Card ID that we retrieved in our useEffect
block.
return (
<ThemeProvider
scaling="90%"
appearance={state.theme.mode}
accentColor={state.theme.color}
>
{state.path === "overview" ? (
<Button onClick={() => onStateChange({ path: "card" })}>
Start card tracking
</Button>
) : (
<Flex direction="column" align="start" gap="3">
<Button
variant="ghost"
onClick={() => onStateChange({ path: "overview" })}
>
← Back to overview
</Button>
<Callout.Root variant="surface">
<Callout.Text>
{state.result
? `The card ID is ${state.result}`
: "Navigate to a card to see its ID"}
</Callout.Text>
</Callout.Root>
</Flex>
)}
</ThemeProvider>
);
Preview your development app in Pine
While we've been previewing our changes in the web browser, it's also possible to test our extensions inside of Pine against workspace data.
To preview an extension, navigate to Pine and click the sidebar icon in the top-right corner to open the extensions sidebar. Then click the vertical ellipsis next to the integrations title to reveal a dropdown menu. Select the preview integration option.

At the bottom of this view, enter the URL of your local development server to preview your application inside the sidebar.
If you encounter issues while running your app, you can click on the vertical ellipsis again to reveal options for reloading and removing the preview extension.
Bundle and upload your app to production
Once you're satisfied with how your extension behaves, we'll now bundle into a single HTML file that is suitable for production. We'll use the vite-plugin-singlefile
plugin to take care of this. Install it in your project:
npm install vite-plugin-singlefile -D
Once installed, update your vite.config.ts
and include viteSingleFile
as a plugin:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
});
You can then finally bundle your project into a single HTML file by running the following:
npm run build
Next, we'll upload the bundled dist/index.html
file to Pine.
To upload the index.html
file contained with the dist
directory, navigate to the integration settings in Pine (if you haven't created an integration, follow the steps outlined here). Navigate to the bottom of the integration page until you reach the extensions section. Under sidebar extension, click the upload button and select your index.html
file.
Click the create/update button at the bottom of the page to finalize your upload. Once you have installed the integration (outlined here), it will appear in your Pine's integrations sidebar.
Congratulations! You have now made a simple extension that displays the card ID of any currently selected card! You now have the foundations to make complex projects extending Pine.

Reference Code
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
html {
color-scheme: normal !important;
}
body {
margin: 0;
}
.radix-themes {
background: transparent;
}
import "@radix-ui/themes/styles.css";
import {
Button,
Callout,
Flex,
Theme as ThemeProvider,
} from "@radix-ui/themes";
import { connectSDKClient, Theme } from "@pinecards/client";
import { useCallback, useEffect, useState } from "react";
const client = connectSDKClient();
interface AppState {
path: "overview" | "card";
theme: Theme;
result: string | null;
}
function App() {
const [state, setState] = useState<AppState>({
path: "overview",
theme: { mode: "light", color: "gold" },
result: null,
});
const onStateChange = useCallback((partialState: Partial<AppState>) => {
setState((state) => ({ ...state, ...partialState }));
}, []);
useEffect(() => {
client.context
.getTheme()
.then((theme) => onStateChange({ theme }))
.catch(() => {
// handle error
});
const promise = client.context.on("themeChange", (event) => {
onStateChange({ theme: event.data });
});
return () => {
promise.then((unsubscribe) => unsubscribe());
};
}, [onStateChange]);
useEffect(() => {
const promise = client.routes.on("routeChange", async (event) => {
const route = event.data;
switch (route.path) {
case "/dates/:dateId/cards/:id":
case "/decks/:deckId/cards/:id":
case "/home/cards/:id":
case "/inbox/cards/:id":
case "/review/cards/:id": {
onStateChange({ result: route.params.id });
break;
}
default:
onStateChange({ result: null });
}
});
return () => {
promise.then((unsubscribe) => unsubscribe());
};
}, [onStateChange]);
return (
<ThemeProvider
scaling="90%"
appearance={state.theme.mode}
accentColor={state.theme.color}
>
{state.path === "overview" ? (
<Button onClick={() => onStateChange({ path: "card" })}>
Start card tracking
</Button>
) : (
<Flex direction="column" align="start" gap="3">
<Button
variant="ghost"
onClick={() => onStateChange({ path: "overview" })}
>
← Back to overview
</Button>
<Callout.Root variant="surface">
<Callout.Text>
{state.result
? `The card ID is ${state.result}`
: "Navigate to a card to see its ID"}
</Callout.Text>
</Callout.Root>
</Flex>
)}
</ThemeProvider>
);
}
export default App;
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
});
Last updated