diff --git a/.changeset/plenty-papers-unite.md b/.changeset/plenty-papers-unite.md
new file mode 100644
index 000000000..7574cf8f1
--- /dev/null
+++ b/.changeset/plenty-papers-unite.md
@@ -0,0 +1,6 @@
+---
+'@solana/plugin-core': minor
+'@solana/kit': minor
+---
+
+Add new `@solana/plugin-core` package enabling us to create modular Kit clients that can be extended with plugins.
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 6b8a19650..9c3415368 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -85,6 +85,7 @@
"@solana/instruction-plans": "workspace:*",
"@solana/keys": "workspace:*",
"@solana/offchain-messages": "workspace:*",
+ "@solana/plugin-core": "workspace:*",
"@solana/programs": "workspace:*",
"@solana/rpc": "workspace:*",
"@solana/rpc-parsed-types": "workspace:*",
diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts
index 0e662a3d7..178468441 100644
--- a/packages/kit/src/index.ts
+++ b/packages/kit/src/index.ts
@@ -15,6 +15,7 @@ export * from '@solana/instructions';
export * from '@solana/instruction-plans';
export * from '@solana/keys';
export * from '@solana/offchain-messages';
+export * from '@solana/plugin-core';
export * from '@solana/programs';
export * from '@solana/rpc';
export * from '@solana/rpc-parsed-types';
diff --git a/packages/plugin-core/.gitignore b/packages/plugin-core/.gitignore
new file mode 100644
index 000000000..aff17b6df
--- /dev/null
+++ b/packages/plugin-core/.gitignore
@@ -0,0 +1,2 @@
+.docs/
+dist/
diff --git a/packages/plugin-core/.npmrc b/packages/plugin-core/.npmrc
new file mode 100644
index 000000000..b6f27f135
--- /dev/null
+++ b/packages/plugin-core/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/packages/plugin-core/.prettierignore b/packages/plugin-core/.prettierignore
new file mode 100644
index 000000000..2bd5f0063
--- /dev/null
+++ b/packages/plugin-core/.prettierignore
@@ -0,0 +1,4 @@
+# Changelogs are autogenerated, so leave them alone
+CHANGELOG.md
+
+dist/
diff --git a/packages/plugin-core/LICENSE b/packages/plugin-core/LICENSE
new file mode 100644
index 000000000..ec09953d3
--- /dev/null
+++ b/packages/plugin-core/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2023 Solana Labs, Inc
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/plugin-core/README.md b/packages/plugin-core/README.md
new file mode 100644
index 000000000..13b36199d
--- /dev/null
+++ b/packages/plugin-core/README.md
@@ -0,0 +1,14 @@
+[![npm][npm-image]][npm-url]
+[![npm-downloads][npm-downloads-image]][npm-url]
+
+[![code-style-prettier][code-style-prettier-image]][code-style-prettier-url]
+
+[code-style-prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square
+[code-style-prettier-url]: https://github.com/prettier/prettier
+[npm-downloads-image]: https://img.shields.io/npm/dm/@solana/plugin-core?style=flat
+[npm-image]: https://img.shields.io/npm/v/@solana/plugin-core?style=flat
+[npm-url]: https://www.npmjs.com/package/@solana/plugin-core
+
+# @solana/plugin-core
+
+This package contains utilities for creating modular Kit clients that can be extended with plugins. It can be used standalone, but it is also exported as part of Kit [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit).
diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json
new file mode 100644
index 000000000..429ace03f
--- /dev/null
+++ b/packages/plugin-core/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "@solana/plugin-core",
+ "version": "5.1.0",
+ "description": "Core helpers for creating and extending Kit clients with plugins",
+ "homepage": "https://www.solanakit.com/api#solanaplugin-core",
+ "exports": {
+ "edge-light": {
+ "import": "./dist/index.node.mjs",
+ "require": "./dist/index.node.cjs"
+ },
+ "workerd": {
+ "import": "./dist/index.node.mjs",
+ "require": "./dist/index.node.cjs"
+ },
+ "browser": {
+ "import": "./dist/index.browser.mjs",
+ "require": "./dist/index.browser.cjs"
+ },
+ "node": {
+ "import": "./dist/index.node.mjs",
+ "require": "./dist/index.node.cjs"
+ },
+ "react-native": "./dist/index.native.mjs",
+ "types": "./dist/types/index.d.ts"
+ },
+ "browser": {
+ "./dist/index.node.cjs": "./dist/index.browser.cjs",
+ "./dist/index.node.mjs": "./dist/index.browser.mjs"
+ },
+ "main": "./dist/index.node.cjs",
+ "module": "./dist/index.node.mjs",
+ "react-native": "./dist/index.native.mjs",
+ "types": "./dist/types/index.d.ts",
+ "type": "commonjs",
+ "files": [
+ "./dist/"
+ ],
+ "sideEffects": false,
+ "keywords": [
+ "blockchain",
+ "solana",
+ "web3"
+ ],
+ "scripts": {
+ "compile:docs": "typedoc",
+ "compile:js": "tsup --config build-scripts/tsup.config.package.ts",
+ "compile:typedefs": "tsc -p ./tsconfig.declarations.json",
+ "dev": "NODE_OPTIONS=\"--localstorage-file=$(mktemp)\" jest -c ../../node_modules/@solana/test-config/jest-dev.config.ts --rootDir . --watch",
+ "prepublishOnly": "pnpm pkg delete devDependencies",
+ "publish-impl": "npm view $npm_package_name@$npm_package_version > /dev/null 2>&1 || (pnpm publish --tag ${PUBLISH_TAG:-canary} --access public --no-git-checks && (([ -n \"${GITHUB_OUTPUT:-}\" ] && echo 'published=true' >> \"$GITHUB_OUTPUT\") || true) && (([ \"$PUBLISH_TAG\" != \"canary\" ] && ../build-scripts/maybe-tag-latest.ts --token \"$GITHUB_TOKEN\" $npm_package_name@$npm_package_version) || true))",
+ "publish-packages": "pnpm prepublishOnly && pnpm publish-impl",
+ "style:fix": "pnpm eslint --fix src && pnpm prettier --log-level warn --ignore-unknown --write ./*",
+ "test:lint": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-lint.config.ts --rootDir . --silent",
+ "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.ts --rootDir . --silent",
+ "test:treeshakability:browser": "agadoo dist/index.browser.mjs",
+ "test:treeshakability:native": "agadoo dist/index.native.mjs",
+ "test:treeshakability:node": "agadoo dist/index.node.mjs",
+ "test:typecheck": "tsc --noEmit",
+ "test:unit:browser": "NODE_OPTIONS=\"--localstorage-file=$(mktemp)\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.browser.ts --rootDir . --silent",
+ "test:unit:node": "NODE_OPTIONS=\"--localstorage-file=$(mktemp)\" TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-unit.config.node.ts --rootDir . --silent"
+ },
+ "author": "Solana Labs Maintainers ",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/anza-xyz/kit"
+ },
+ "bugs": {
+ "url": "https://github.com/anza-xyz/kit/issues"
+ },
+ "browserslist": [
+ "supports bigint and not dead",
+ "maintained node versions"
+ ],
+ "peerDependencies": {
+ "typescript": ">=5.3.3"
+ },
+ "engines": {
+ "node": ">=20.18.0"
+ }
+}
diff --git a/packages/plugin-core/src/__tests__/client-test.ts b/packages/plugin-core/src/__tests__/client-test.ts
new file mode 100644
index 000000000..414322e82
--- /dev/null
+++ b/packages/plugin-core/src/__tests__/client-test.ts
@@ -0,0 +1,154 @@
+import '@solana/test-matchers/toBeFrozenObject';
+
+import { createEmptyClient } from '../client';
+
+describe('createEmptyClient', () => {
+ it('creates an empty object with a use function', () => {
+ const emptyClient = createEmptyClient();
+ expect(typeof emptyClient).toBe('object');
+ const attributes = Object.getOwnPropertyNames(emptyClient);
+ expect(attributes).toStrictEqual(['use']);
+ expect(typeof emptyClient.use).toBe('function');
+ });
+
+ it('evolves when using plugins', () => {
+ expect(
+ createEmptyClient()
+ .use(c => ({ ...c, fruit: 'apple' as const }))
+ .use(c => ({ ...c, vegetable: 'carrot' as const })),
+ ).toStrictEqual({
+ fruit: 'apple',
+ use: expect.any(Function),
+ vegetable: 'carrot',
+ });
+ });
+
+ it('can be overriden by subsequent plugins', () => {
+ expect(
+ createEmptyClient()
+ .use(() => ({ fruit: 'apple' as const }))
+ .use(() => ({ vegetable: 'carrot' as const })),
+ ).toStrictEqual({
+ use: expect.any(Function),
+ vegetable: 'carrot',
+ });
+ });
+
+ it('allows plugins to enforce input type constraints', () => {
+ expect(
+ createEmptyClient()
+ .use(c => ({ ...c, fruit: 'apple' as const }))
+ .use((c: T) => ({ ...c, dessert: 'apple cake' as const })),
+ ).toStrictEqual({
+ dessert: 'apple cake',
+ fruit: 'apple',
+ use: expect.any(Function),
+ });
+ });
+
+ it('supports asynchronous plugins', async () => {
+ expect.assertions(1);
+ await expect(
+ createEmptyClient()
+ .use(c => Promise.resolve({ ...c, fruit: 'apple' as const }))
+ .use(c => Promise.resolve({ ...c, vegetable: 'carrot' as const })),
+ ).resolves.toStrictEqual({
+ fruit: 'apple',
+ use: expect.any(Function),
+ vegetable: 'carrot',
+ });
+ });
+
+ it('supports a mixture of synchronous and asynchronous plugins', async () => {
+ expect.assertions(1);
+ await expect(
+ createEmptyClient()
+ .use(c => ({ ...c, fruit: 'apple' as const }))
+ .use(c => Promise.resolve({ ...c, vegetable: 'carrot' as const }))
+ .use(c => ({ ...c, grain: 'rice' as const }))
+ .use(c => Promise.resolve({ ...c, protein: 'beans' as const })),
+ ).resolves.toStrictEqual({
+ fruit: 'apple',
+ grain: 'rice',
+ protein: 'beans',
+ use: expect.any(Function),
+ vegetable: 'carrot',
+ });
+ });
+
+ it('can catch synchronous errors', () => {
+ expect(() =>
+ createEmptyClient().use(() => {
+ throw new Error('Missing fruit');
+ }),
+ ).toThrow('Missing fruit');
+ });
+
+ it('can catch asynchronous errors', async () => {
+ expect.assertions(1);
+ await expect(
+ createEmptyClient().use(() => {
+ return Promise.reject(new Error('Missing fruit'));
+ }),
+ ).rejects.toThrow('Missing fruit');
+ });
+
+ it('can chain the then function on the async client', async () => {
+ expect.assertions(1);
+ const thenFn = jest.fn();
+ await createEmptyClient()
+ .use(() => Promise.resolve({ fruit: 'apple' as const }))
+ .then(thenFn);
+ expect(thenFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ fruit: 'apple' }));
+ });
+
+ it('can chain the catch function on the async client', async () => {
+ expect.assertions(1);
+ const catchFn = jest.fn();
+ await createEmptyClient()
+ .use(() => Promise.reject(new Error('Missing fruit')))
+ .catch(catchFn);
+ expect(catchFn).toHaveBeenNthCalledWith(1, expect.objectContaining({ message: 'Missing fruit' }));
+ });
+
+ it('can chain the finally function on the async client when successful', async () => {
+ expect.assertions(1);
+ const finallyFn = jest.fn();
+ await createEmptyClient()
+ .use(() => Promise.resolve({ fruit: 'apple' as const }))
+ .finally(finallyFn);
+ expect(finallyFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('can chain the finally function on the async client when unsuccessful', async () => {
+ expect.assertions(1);
+ const finallyFn = jest.fn();
+ await createEmptyClient()
+ .use(() => Promise.reject(new Error('Missing fruit')))
+ .finally(finallyFn)
+ .catch(() => {});
+ expect(finallyFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not resolve subsequent asynchronous plugins after an error', async () => {
+ expect.assertions(1);
+ const subsequentPlugin = jest.fn();
+ await createEmptyClient()
+ .use(() => Promise.reject(new Error('Missing fruit')))
+ .use(subsequentPlugin)
+ .catch(() => {});
+ expect(subsequentPlugin).not.toHaveBeenCalled();
+ });
+
+ it('returns a frozen object when empty', () => {
+ expect(createEmptyClient()).toBeFrozenObject();
+ });
+
+ it('returns a frozen object when extended by a plugin', () => {
+ expect(createEmptyClient().use(() => ({ fruit: 'apple' as const }))).toBeFrozenObject();
+ });
+
+ it('returns a frozen object when extended by an asynchronous plugin', () => {
+ expect(createEmptyClient().use(() => Promise.resolve({ fruit: 'apple' as const }))).toBeFrozenObject();
+ });
+});
diff --git a/packages/plugin-core/src/__typetests__/client-typetest.ts b/packages/plugin-core/src/__typetests__/client-typetest.ts
new file mode 100644
index 000000000..09859aeaa
--- /dev/null
+++ b/packages/plugin-core/src/__typetests__/client-typetest.ts
@@ -0,0 +1,206 @@
+/* eslint-disable @typescript-eslint/no-floating-promises */
+import { type AsyncClient, type Client, type ClientPlugin, createEmptyClient } from '../client';
+
+const EMPTY_CLIENT = null as unknown as Client