├── .flake8 ├── .github └── workflows │ ├── lint.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── aiohttp_poe ├── README.md ├── py.typed ├── pyproject.toml └── src │ └── aiohttp_poe │ ├── __init__.py │ ├── __main__.py │ ├── base.py │ ├── samples │ └── echo.py │ └── types.py ├── fastapi_poe ├── README.md ├── py.typed ├── pyproject.toml └── src │ └── fastapi_poe │ ├── __init__.py │ ├── __main__.py │ ├── base.py │ ├── client.py │ ├── samples │ └── echo.py │ └── types.py ├── images └── chat-sample.png ├── langchain_poe ├── README.md ├── pyproject.toml └── src │ └── langchain_poe │ ├── __init__.py │ ├── __main__.py │ └── poe.py ├── llama_poe ├── Makefile ├── README.md ├── data │ ├── apps.md │ ├── paul_graham_essay.txt │ └── queries.md ├── index.json ├── poe_api │ ├── __init__.py │ ├── llama_handler.py │ ├── server.py │ ├── types.py │ └── utils.py ├── poetry.lock └── pyproject.toml ├── pyproject.toml ├── simulator_poe ├── README.md ├── poe_server.png ├── pyproject.toml └── src │ └── simulator_poe │ ├── __init__.py │ ├── __main__.py │ ├── async_bot_client.py │ ├── poe_messages.py │ └── poe_server.py └── spec.md /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | extend-immutable-calls = Depends, fastapi.Depends, fastapi.params.Depends 4 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | precommit: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up latest Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | 17 | - name: Run pre-commit hooks 18 | uses: pre-commit/action@v3.0.0 19 | 20 | pyright: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - name: Set up latest Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: "3.11" 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install aiohttp_poe/ fastapi_poe/ 35 | 36 | - uses: jakebailey/pyright-action@v1 37 | with: 38 | working-directory: aiohttp_poe 39 | - uses: jakebailey/pyright-action@v1 40 | with: 41 | working-directory: fastapi_poe 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Based on 2 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: Publish Python distributions to PyPI and TestPyPI 5 | 6 | on: push 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python distributions to PyPI and TestPyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.10" 18 | - name: Install pypa/build 19 | run: >- 20 | python -m pip install build --user 21 | - name: Build a binary wheel and a source tarball (fastapi-poe) 22 | run: >- 23 | python -m build --sdist --wheel --outdir dist/ fastapi_poe/ 24 | - name: Publish distribution to PyPI 25 | if: startsWith(github.ref, 'refs/tags') 26 | uses: pypa/gh-action-pypi-publish@master 27 | with: 28 | password: ${{ secrets.PYPI_API_TOKEN_FASTAPI }} 29 | - name: Clean up 30 | run: rm -rf dist/ 31 | - name: Build a binary wheel and a source tarball (aiohttp-poe) 32 | run: >- 33 | python -m build --sdist --wheel --outdir dist/ aiohttp_poe/ 34 | - name: Publish distribution to PyPI 35 | if: startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | password: ${{ secrets.PYPI_API_TOKEN_AIOHTTP }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | **/venv 3 | .vscode/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Order matters because pyupgrade may make changes that pycln and black have to clean up 3 | - repo: https://github.com/asottile/pyupgrade 4 | rev: v3.8.0 5 | hooks: 6 | - id: pyupgrade 7 | args: [--py37-plus] 8 | 9 | - repo: https://github.com/hadialqattan/pycln 10 | rev: v2.1.5 11 | hooks: 12 | - id: pycln 13 | args: [--config=pyproject.toml] 14 | 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.12.0 17 | hooks: 18 | - id: isort 19 | name: isort (python) 20 | 21 | - repo: https://github.com/psf/black 22 | rev: 23.3.0 23 | hooks: 24 | - id: black 25 | language_version: python3.11 26 | 27 | - repo: https://github.com/pre-commit/pre-commit-hooks 28 | rev: v4.4.0 29 | hooks: 30 | - id: end-of-file-fixer 31 | - id: trailing-whitespace 32 | 33 | - repo: https://github.com/pycqa/flake8 34 | rev: 6.0.0 35 | hooks: 36 | - id: flake8 37 | additional_dependencies: 38 | - flake8-bugbear 39 | - flake8-comprehensions 40 | - flake8-simplify 41 | 42 | - repo: https://github.com/pre-commit/mirrors-prettier 43 | rev: v3.0.0-alpha.9-for-vscode 44 | hooks: 45 | - id: prettier 46 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | proseWrap: always 2 | printWidth: 88 3 | endOfLine: auto 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # General 2 | 3 | - All changes should be made through pull requests 4 | - Pull requests should only be merged once all checks pass 5 | - The repo uses Black for formatting Python code, Prettier for formatting Markdown, 6 | Pyright for type-checking Python, and a few other tools 7 | - To run the CI checks locally: 8 | - `pip install pre-commit` 9 | - `pre-commit run --all` (or `pre-commit install` to install the pre-commit hook) 10 | 11 | # Spec changes 12 | 13 | All spec changes should come with: 14 | 15 | - An enhancement to the [catbot](/docs/catbot.md) document that provides an example of 16 | how to use the new feature. 17 | - Changes to the `fastapi_poe` and `aiohttp_poe` client libraries so that users can use 18 | the new feature. 19 | 20 | # Releases 21 | 22 | To release a new version of the `fastapi-poe` and `aiohttp-poe` client libraries, do the 23 | following: 24 | 25 | - Make a PR updating the version number in `aiohttp_poe/pyproject.toml` and 26 | `fastapi_poe/pyproject.toml` (example: 27 | https://github.com/poe-platform/poe-protocol/pull/28) 28 | - Merge it once CI passes 29 | - Go to https://github.com/poe-platform/poe-protocol/releases/new and make a new release 30 | (note this link works only if you have commit access to this repository) 31 | - The tag should be of the form "v0.0.X" for now. 32 | 33 | Once the protocol is finalized, the version number should track the protocol version 34 | number, and we'll start maintaining a more organized changelog. 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repo is being archived. As replacements, see: 2 | 3 | - [Documentation](https://developer.poe.com/) developed at the 4 | [documentation repo](https://github.com/poe-platform/documentation) 5 | - [Tutorial repo](https://github.com/poe-platform/api-bot-tutorial) 6 | - [fastapi-poe](https://github.com/poe-platform/fastapi-poe) 7 | -------------------------------------------------------------------------------- /aiohttp_poe/README.md: -------------------------------------------------------------------------------- 1 | # aiohttp_poe 2 | 3 | An implementation of the Poe protocol using aiohttp. 4 | 5 | To run it: 6 | 7 | - Create a virtual environment (Python 3.7 or higher) 8 | - `pip install .` 9 | - `python -m aiohttp_poe` 10 | - In a different terminal, run [ngrok](https://ngrok.com/) to make it publicly 11 | accessible 12 | 13 | ## Write your own bot 14 | 15 | This package can also be used as a base to write your own bot. You can inherit from 16 | `aiohttp_poe.PoeBot` to make a bot: 17 | 18 | ```python 19 | from aiohttp_poe import PoeBot, run 20 | 21 | class EchoBot(PoeBot): 22 | async def get_response(self, query, request): 23 | last_message = query["query"][-1]["content"] 24 | yield self.text_event(last_message) 25 | 26 | 27 | if __name__ == "__main__": 28 | run(EchoBot()) 29 | ``` 30 | 31 | ## Enable authentication 32 | 33 | Poe servers send requests containing Authorization HTTP header in the format "Bearer 34 | ," where api_key is the API key configured in the bot settings. \ 35 | 36 | To validate the requests are from Poe Servers, you can either set the environment 37 | variable POE_API_KEY or pass the parameter api_key in the run function like: 38 | 39 | ```python 40 | if __name__ == "__main__": 41 | run(EchoBot(), api_key=) 42 | ``` 43 | 44 | For a more advanced example that exercises more of the Poe protocol, see 45 | [Catbot](./src/aiohttp_poe/samples/catbot.py). 46 | -------------------------------------------------------------------------------- /aiohttp_poe/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/aiohttp_poe/py.typed -------------------------------------------------------------------------------- /aiohttp_poe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "aiohttp_poe" 7 | version = "0.0.14" 8 | authors = [ 9 | { name="Jelle Zijlstra", email="jelle@quora.com" }, 10 | ] 11 | description = "A demonstration of the Poe protocol using aiohttp" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "aiohttp", 21 | "aiohttp-sse", 22 | "typing-extensions", 23 | ] 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/quora/poe-protocol" 27 | 28 | [tool.pyright] 29 | pythonVersion = "3.7" 30 | -------------------------------------------------------------------------------- /aiohttp_poe/src/aiohttp_poe/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["PoeBot", "run"] 2 | 3 | from .base import PoeBot, run 4 | -------------------------------------------------------------------------------- /aiohttp_poe/src/aiohttp_poe/__main__.py: -------------------------------------------------------------------------------- 1 | from aiohttp_poe.base import run 2 | from aiohttp_poe.samples.echo import EchoBot 3 | 4 | if __name__ == "__main__": 5 | run(EchoBot()) 6 | -------------------------------------------------------------------------------- /aiohttp_poe/src/aiohttp_poe/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | import json 6 | import os 7 | import sys 8 | from typing import AsyncIterator, Awaitable, Callable 9 | 10 | from aiohttp import web 11 | from aiohttp_sse import EventSourceResponse, sse_response 12 | 13 | from .types import ( 14 | ContentType, 15 | ErrorEvent, 16 | Event, 17 | QueryRequest, 18 | ReportErrorRequest, 19 | ReportFeedbackRequest, 20 | SettingsResponse, 21 | ) 22 | 23 | 24 | # We need to override this to allow POST requests to use SSE 25 | class _SSEResponse(EventSourceResponse): 26 | async def prepare(self, request: web.Request): 27 | if not self.prepared: 28 | writer = await web.StreamResponse.prepare(self, request) 29 | self._ping_task = asyncio.create_task(self._ping()) 30 | # explicitly enabling chunked encoding, since content length 31 | # usually not known beforehand. 32 | self.enable_chunked_encoding() 33 | return writer 34 | else: 35 | # hackish way to check if connection alive 36 | # should be updated once we have proper API in aiohttp 37 | # https://github.com/aio-libs/aiohttp/issues/3105 38 | if request.protocol.transport is None: 39 | # request disconnected 40 | raise asyncio.CancelledError() 41 | 42 | 43 | async def authenticate(request: web.Request, token: str) -> bool: 44 | if auth_key is not None and token != auth_key: 45 | return False 46 | return True 47 | 48 | 49 | @web.middleware 50 | async def auth_middleware(request: web.Request, handler): 51 | auth_header = request.headers.get("Authorization") 52 | if auth_header: 53 | scheme, _, token = auth_header.partition(" ") 54 | if scheme.lower() == "bearer": 55 | is_authenticated = await authenticate(request, token) 56 | if is_authenticated: 57 | return await handler(request) 58 | return web.HTTPUnauthorized(headers={"WWW-Authenticate": "Bearer"}) 59 | 60 | 61 | class PoeBot: 62 | async def __call__(self, request: web.Request) -> web.Response: 63 | body = await request.json() 64 | request_type = body["type"] 65 | if request_type == "query": 66 | await self.__handle_query(body, request) 67 | # Apparently aiohttp's types don't work well with whatever aiohttp_sse 68 | # is doing to create a streaming response. 69 | return None # type: ignore 70 | elif request_type == "settings": 71 | settings = await self.get_settings() 72 | return web.Response( 73 | text=json.dumps(settings), content_type="application/json" 74 | ) 75 | elif request_type == "report_feedback": 76 | await self.on_feedback(body) 77 | return web.Response(text="{}", content_type="application/json") 78 | elif request_type == "report_error": 79 | await self.on_error(body) 80 | return web.Response(text="{}", content_type="application/json") 81 | else: 82 | return web.Response( 83 | status=501, text="Unsupported request type", reason="Not Implemented" 84 | ) 85 | 86 | async def __handle_query(self, query: QueryRequest, request: web.Request) -> None: 87 | async with sse_response(request, response_cls=_SSEResponse) as resp: 88 | async for event_type, data in self.get_response(query, request): 89 | await resp.send(json.dumps(data), event=event_type) 90 | await resp.send("{}", event="done") 91 | 92 | @staticmethod 93 | def text_event(text: str) -> Event: 94 | return ("text", {"text": text}) 95 | 96 | @staticmethod 97 | def replace_response_event(text: str) -> Event: 98 | return ("replace_response", {"text": text}) 99 | 100 | @staticmethod 101 | def suggested_reply_event(text: str) -> Event: 102 | return ("suggested_reply", {"text": text}) 103 | 104 | @staticmethod 105 | def meta_event( 106 | *, 107 | content_type: ContentType = "text/markdown", 108 | refetch_settings: bool = False, 109 | linkify: bool = True, 110 | suggested_replies: bool = True, 111 | ) -> Event: 112 | return ( 113 | "meta", 114 | { 115 | "content_type": content_type, 116 | "refetch_settings": refetch_settings, 117 | "linkify": linkify, 118 | "suggested_replies": suggested_replies, 119 | }, 120 | ) 121 | 122 | @staticmethod 123 | def error_event(text: str | None = None, *, allow_retry: bool = True) -> Event: 124 | data: ErrorEvent = {"allow_retry": allow_retry} 125 | if text is not None: 126 | data["text"] = text 127 | return ("error", data) 128 | 129 | # Methods that may be overridden by subclasses 130 | 131 | def get_response( 132 | self, query: QueryRequest, request: web.Request 133 | ) -> AsyncIterator[Event]: 134 | """Return an async iterator of events to send to the user.""" 135 | raise NotImplementedError 136 | 137 | async def on_error(self, error: ReportErrorRequest) -> None: 138 | """Called when the Poe server reports an error.""" 139 | print("Received error:", error) 140 | 141 | async def on_feedback(self, feedback: ReportFeedbackRequest) -> None: 142 | """Called when we receive user feedback such as likes.""" 143 | pass 144 | 145 | async def get_settings(self) -> SettingsResponse: 146 | """Return the settings for this bot.""" 147 | return {} 148 | 149 | 150 | async def index(request: web.Request) -> web.Response: 151 | url = "https://poe.com/create_bot?api=1" 152 | return web.Response( 153 | text="

aiohttp Poe bot server

Congratulations! Your server" 154 | " is running. To connect it to Poe, create a bot at {url}.

', 156 | content_type="text/html", 157 | ) 158 | 159 | 160 | def find_auth_key(api_key: str, *, allow_without_key: bool = False) -> str | None: 161 | if not api_key: 162 | if os.environ.get("POE_API_KEY"): 163 | api_key = os.environ["POE_API_KEY"] 164 | else: 165 | if allow_without_key: 166 | return None 167 | print( 168 | "Please provide an API key. You can get a key from the create_bot form at:" 169 | ) 170 | print("https://poe.com/create_bot?api=1") 171 | print( 172 | "You can pass the API key to the run() function or " 173 | "use the POE_API_KEY environment variable." 174 | ) 175 | sys.exit(1) 176 | if len(api_key) != 32: 177 | print("Invalid API key (should be 32 characters)") 178 | sys.exit(1) 179 | return api_key 180 | 181 | 182 | def run( 183 | bot: Callable[[web.Request], Awaitable[web.Response]], 184 | api_key: str = "", 185 | *, 186 | allow_without_key: bool = False, 187 | ) -> None: 188 | parser = argparse.ArgumentParser("aiohttp sample Poe bot server") 189 | parser.add_argument("-p", "--port", type=int, default=8080) 190 | args = parser.parse_args() 191 | 192 | global auth_key 193 | auth_key = find_auth_key(api_key, allow_without_key=allow_without_key) 194 | 195 | app = web.Application(middlewares=[auth_middleware]) 196 | app.add_routes([web.get("/", index)]) 197 | app.add_routes([web.post("/", bot)]) 198 | web.run_app(app, port=args.port) 199 | -------------------------------------------------------------------------------- /aiohttp_poe/src/aiohttp_poe/samples/echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Sample bot that echoes back messages. 4 | 5 | """ 6 | from __future__ import annotations 7 | 8 | from typing import AsyncIterator 9 | 10 | from aiohttp import web 11 | 12 | from aiohttp_poe import PoeBot, run 13 | from aiohttp_poe.types import Event, QueryRequest 14 | 15 | 16 | class EchoBot(PoeBot): 17 | async def get_response( 18 | self, query: QueryRequest, request: web.Request 19 | ) -> AsyncIterator[Event]: 20 | """Return an async iterator of events to send to the user.""" 21 | last_message = query["query"][-1]["content"] 22 | yield self.text_event(last_message) 23 | 24 | 25 | if __name__ == "__main__": 26 | run(EchoBot()) 27 | -------------------------------------------------------------------------------- /aiohttp_poe/src/aiohttp_poe/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Tuple, Union 4 | 5 | from typing_extensions import Literal, NotRequired, TypeAlias, TypedDict 6 | 7 | Identifier: TypeAlias = str 8 | FeedbackType: TypeAlias = Literal["like", "dislike"] 9 | ContentType: TypeAlias = Literal["text/markdown", "text/plain"] 10 | 11 | 12 | class MessageFeedback(TypedDict): 13 | """Feedback for a message as used in the Poe protocol.""" 14 | 15 | type: FeedbackType 16 | reason: NotRequired[str] 17 | 18 | 19 | class ProtocolMessage(TypedDict): 20 | """A message as used in the Poe protocol.""" 21 | 22 | role: Literal["system", "user", "bot"] 23 | content: str 24 | content_type: ContentType 25 | timestamp: int 26 | message_id: str 27 | feedback: list[MessageFeedback] 28 | 29 | 30 | class BaseRequest(TypedDict): 31 | """Common data for all requests.""" 32 | 33 | version: str 34 | type: Literal["query", "settings", "report_feedback", "report_error"] 35 | 36 | 37 | class QueryRequest(BaseRequest): 38 | """Request parameters for a query request.""" 39 | 40 | query: list[ProtocolMessage] 41 | user_id: Identifier 42 | conversation_id: Identifier 43 | message_id: Identifier 44 | 45 | 46 | class MetaEvent(TypedDict): 47 | content_type: NotRequired[ContentType] 48 | linkify: NotRequired[bool] 49 | refetch_settings: NotRequired[bool] 50 | suggested_replies: NotRequired[bool] 51 | 52 | 53 | class TextEvent(TypedDict): 54 | text: str 55 | 56 | 57 | class ReplaceResponseEvent(TypedDict): 58 | text: str 59 | 60 | 61 | class SuggestedReplyEvent(TypedDict): 62 | text: str 63 | 64 | 65 | class ErrorEvent(TypedDict): 66 | allow_retry: NotRequired[bool] 67 | text: NotRequired[str] 68 | 69 | 70 | class DoneEvent(TypedDict): 71 | pass # no fields 72 | 73 | 74 | Event: TypeAlias = Union[ 75 | Tuple[Literal["meta"], MetaEvent], 76 | Tuple[Literal["text"], TextEvent], 77 | Tuple[Literal["replace_response"], ReplaceResponseEvent], 78 | Tuple[Literal["suggested_reply"], SuggestedReplyEvent], 79 | Tuple[Literal["error"], ErrorEvent], 80 | Tuple[Literal["done"], DoneEvent], 81 | ] 82 | 83 | 84 | class SettingsRequest(BaseRequest): 85 | """Request parameters for a settings request.""" 86 | 87 | 88 | class ReportFeedbackRequest(BaseRequest): 89 | """Request parameters for a report_feedback request.""" 90 | 91 | message_id: Identifier 92 | user_id: Identifier 93 | conversation_id: Identifier 94 | feedback_type: FeedbackType 95 | 96 | 97 | class ReportErrorRequest(TypedDict): 98 | """Request parameters for a report_error request.""" 99 | 100 | message: str 101 | metadata: dict[str, Any] 102 | 103 | 104 | class SettingsResponse(TypedDict): 105 | context_clear_window_secs: NotRequired[int | None] 106 | allow_user_context_clear: NotRequired[bool] 107 | -------------------------------------------------------------------------------- /fastapi_poe/README.md: -------------------------------------------------------------------------------- 1 | # fastapi_poe 2 | 3 | An implementation of the Poe protocol using FastAPI. 4 | 5 | To run it: 6 | 7 | - Create a virtual environment (Python 3.7 or higher) 8 | - `pip install .` 9 | - `python -m fastapi_poe` 10 | - In a different terminal, run [ngrok](https://ngrok.com/) to make it publicly 11 | accessible 12 | 13 | ## Write your own bot 14 | 15 | This package can also be used as a base to write your own bot. You can inherit from 16 | `fastapi_poe.PoeBot` to make a bot: 17 | 18 | ```python 19 | from fastapi_poe import PoeBot, run 20 | 21 | class EchoBot(PoeBot): 22 | async def get_response(self, query): 23 | last_message = query.query[-1].content 24 | yield self.text_event(last_message) 25 | 26 | if __name__ == "__main__": 27 | run(EchoBot()) 28 | ``` 29 | 30 | ## Enable authentication 31 | 32 | Poe servers send requests containing Authorization HTTP header in the format "Bearer 33 | ," where api_key is the API key configured in the bot settings. \ 34 | 35 | To validate the requests are from Poe Servers, you can either set the environment 36 | variable POE_API_KEY or pass the parameter api_key in the run function like: 37 | 38 | ```python 39 | if __name__ == "__main__": 40 | run(EchoBot(), api_key=) 41 | ``` 42 | -------------------------------------------------------------------------------- /fastapi_poe/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/fastapi_poe/py.typed -------------------------------------------------------------------------------- /fastapi_poe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "fastapi_poe" 7 | version = "0.0.14" 8 | authors = [ 9 | { name="Lida Li", email="lli@quora.com" }, 10 | ] 11 | description = "A demonstration of the Poe protocol using FastAPI" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "fastapi", 21 | "sse-starlette", 22 | "typing-extensions", 23 | "uvicorn", 24 | "httpx", 25 | "httpx-sse", 26 | ] 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/quora/poe-protocol" 30 | 31 | [tool.pyright] 32 | pythonVersion = "3.7" 33 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["PoeBot", "run", "make_app"] 2 | 3 | from .base import PoeBot, make_app, run 4 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/__main__.py: -------------------------------------------------------------------------------- 1 | from fastapi_poe import run 2 | from fastapi_poe.samples.echo import EchoBot 3 | 4 | if __name__ == "__main__": 5 | run(EchoBot()) 6 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/base.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import copy 3 | import json 4 | import logging 5 | import os 6 | import sys 7 | from typing import Any, AsyncIterable, Dict, Optional, Union 8 | 9 | from fastapi import Depends, FastAPI, HTTPException, Request, Response 10 | from fastapi.exceptions import RequestValidationError 11 | from fastapi.responses import HTMLResponse, JSONResponse 12 | from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 13 | from sse_starlette.sse import EventSourceResponse, ServerSentEvent 14 | from starlette.middleware.base import BaseHTTPMiddleware 15 | 16 | from fastapi_poe.types import ( 17 | ContentType, 18 | QueryRequest, 19 | ReportErrorRequest, 20 | ReportFeedbackRequest, 21 | SettingsRequest, 22 | SettingsResponse, 23 | ) 24 | 25 | logger = logging.getLogger("uvicorn.default") 26 | 27 | 28 | class LoggingMiddleware(BaseHTTPMiddleware): 29 | async def set_body(self, request: Request): 30 | receive_ = await request._receive() 31 | 32 | async def receive(): 33 | return receive_ 34 | 35 | request._receive = receive 36 | 37 | async def dispatch(self, request: Request, call_next): 38 | logger.info(f"Request: {request.method} {request.url}") 39 | try: 40 | # Per https://github.com/tiangolo/fastapi/issues/394#issuecomment-927272627 41 | # to avoid blocking. 42 | await self.set_body(request) 43 | body = await request.json() 44 | logger.debug(f"Request body: {json.dumps(body)}") 45 | except json.JSONDecodeError: 46 | logger.error("Request body: Unable to parse JSON") 47 | 48 | response = await call_next(request) 49 | 50 | logger.info(f"Response status: {response.status_code}") 51 | try: 52 | if hasattr(response, "body"): 53 | body = json.loads(response.body.decode()) 54 | logger.debug(f"Response body: {json.dumps(body)}") 55 | except json.JSONDecodeError: 56 | logger.error("Response body: Unable to parse JSON") 57 | 58 | return response 59 | 60 | 61 | def exception_handler(request: Request, ex: HTTPException): 62 | logger.error(ex) 63 | 64 | 65 | http_bearer = HTTPBearer() 66 | 67 | 68 | def auth_user( 69 | authorization: HTTPAuthorizationCredentials = Depends(http_bearer), 70 | ) -> None: 71 | if auth_key is None: 72 | return 73 | if authorization.scheme != "Bearer" or authorization.credentials != auth_key: 74 | raise HTTPException( 75 | status_code=401, 76 | detail="Invalid API key", 77 | headers={"WWW-Authenticate": "Bearer"}, 78 | ) 79 | 80 | 81 | class PoeBot: 82 | # Override these for your bot 83 | 84 | async def get_response(self, query: QueryRequest) -> AsyncIterable[ServerSentEvent]: 85 | """Override this to return a response to user queries.""" 86 | yield self.text_event("hello") 87 | 88 | async def get_settings(self, setting: SettingsRequest) -> SettingsResponse: 89 | """Override this to return non-standard settings.""" 90 | return SettingsResponse() 91 | 92 | async def on_feedback(self, feedback_request: ReportFeedbackRequest) -> None: 93 | """Override this to record feedback from the user.""" 94 | pass 95 | 96 | async def on_error(self, error_request: ReportErrorRequest) -> None: 97 | """Override this to record errors from the Poe server.""" 98 | logger.error(f"Error from Poe server: {error_request}") 99 | 100 | # Helpers for generating responses 101 | 102 | @staticmethod 103 | def text_event(text: str) -> ServerSentEvent: 104 | return ServerSentEvent(data=json.dumps({"text": text}), event="text") 105 | 106 | @staticmethod 107 | def replace_response_event(text: str) -> ServerSentEvent: 108 | return ServerSentEvent( 109 | data=json.dumps({"text": text}), event="replace_response" 110 | ) 111 | 112 | @staticmethod 113 | def done_event() -> ServerSentEvent: 114 | return ServerSentEvent(data="{}", event="done") 115 | 116 | @staticmethod 117 | def suggested_reply_event(text: str) -> ServerSentEvent: 118 | return ServerSentEvent(data=json.dumps({"text": text}), event="suggested_reply") 119 | 120 | @staticmethod 121 | def meta_event( 122 | *, 123 | content_type: ContentType = "text/markdown", 124 | refetch_settings: bool = False, 125 | linkify: bool = True, 126 | suggested_replies: bool = True, 127 | ) -> ServerSentEvent: 128 | return ServerSentEvent( 129 | data=json.dumps( 130 | { 131 | "content_type": content_type, 132 | "refetch_settings": refetch_settings, 133 | "linkify": linkify, 134 | "suggested_replies": suggested_replies, 135 | } 136 | ), 137 | event="meta", 138 | ) 139 | 140 | @staticmethod 141 | def error_event( 142 | text: Optional[str] = None, *, allow_retry: bool = True 143 | ) -> ServerSentEvent: 144 | data: Dict[str, Union[bool, str]] = {"allow_retry": allow_retry} 145 | if text is not None: 146 | data["text"] = text 147 | return ServerSentEvent(data=json.dumps(data), event="error") 148 | 149 | # Internal handlers 150 | 151 | async def handle_report_feedback( 152 | self, feedback_request: ReportFeedbackRequest 153 | ) -> JSONResponse: 154 | await self.on_feedback(feedback_request) 155 | return JSONResponse({}) 156 | 157 | async def handle_report_error( 158 | self, error_request: ReportErrorRequest 159 | ) -> JSONResponse: 160 | await self.on_error(error_request) 161 | return JSONResponse({}) 162 | 163 | async def handle_settings(self, settings_request: SettingsRequest) -> JSONResponse: 164 | settings = await self.get_settings(settings_request) 165 | return JSONResponse(settings.dict()) 166 | 167 | async def handle_query(self, query: QueryRequest) -> AsyncIterable[ServerSentEvent]: 168 | try: 169 | async for event in self.get_response(query): 170 | yield event 171 | except Exception as e: 172 | logger.exception("Error responding to query") 173 | yield self.error_event(repr(e), allow_retry=False) 174 | yield self.done_event() 175 | 176 | 177 | def find_auth_key(api_key: str, *, allow_without_key: bool = False) -> Optional[str]: 178 | if not api_key: 179 | if os.environ.get("POE_API_KEY"): 180 | api_key = os.environ["POE_API_KEY"] 181 | else: 182 | if allow_without_key: 183 | return None 184 | print( 185 | "Please provide an API key. You can get a key from the create_bot form at:" 186 | ) 187 | print("https://poe.com/create_bot?api=1") 188 | print( 189 | "You can pass the API key to the run() function or " 190 | "use the POE_API_KEY environment variable." 191 | ) 192 | sys.exit(1) 193 | if len(api_key) != 32: 194 | print("Invalid API key (should be 32 characters)") 195 | sys.exit(1) 196 | return api_key 197 | 198 | 199 | def make_app( 200 | bot: PoeBot, api_key: str = "", *, allow_without_key: bool = False 201 | ) -> FastAPI: 202 | """Create an app object. Arguments are as for run().""" 203 | app = FastAPI() 204 | app.add_exception_handler(RequestValidationError, exception_handler) 205 | 206 | global auth_key 207 | auth_key = find_auth_key(api_key, allow_without_key=allow_without_key) 208 | 209 | @app.get("/") 210 | async def index() -> Response: 211 | url = "https://poe.com/create_bot?api=1" 212 | return HTMLResponse( 213 | "

FastAPI Poe bot server

Congratulations! Your server" 214 | " is running. To connect it to Poe, create a bot at {url}.

' 216 | ) 217 | 218 | @app.post("/") 219 | async def poe_post(request: Dict[str, Any], dict=Depends(auth_user)) -> Response: 220 | if request["type"] == "query": 221 | return EventSourceResponse( 222 | bot.handle_query( 223 | QueryRequest.parse_obj( 224 | {**request, "api_key": auth_key or ""} 225 | ) 226 | ) 227 | ) 228 | elif request["type"] == "settings": 229 | return await bot.handle_settings(SettingsRequest.parse_obj(request)) 230 | elif request["type"] == "report_feedback": 231 | return await bot.handle_report_feedback( 232 | ReportFeedbackRequest.parse_obj(request) 233 | ) 234 | elif request["type"] == "report_error": 235 | return await bot.handle_report_error(ReportErrorRequest.parse_obj(request)) 236 | else: 237 | raise HTTPException(status_code=501, detail="Unsupported request type") 238 | 239 | # Uncomment this line to print out request and response 240 | # app.add_middleware(LoggingMiddleware) 241 | return app 242 | 243 | 244 | def run(bot: PoeBot, api_key: str = "", *, allow_without_key: bool = False) -> None: 245 | """ 246 | Run a Poe bot server using FastAPI. 247 | 248 | :param bot: The bot object. 249 | :param api_key: The Poe API key to use. If not provided, it will try to read 250 | the POE_API_KEY environment variable. If that is not set, the server will 251 | refuse to start, unless *allow_without_key* is True. 252 | :param allow_without_key: If True, the server will start even if no API key 253 | is provided. Requests will not be checked against any key. If an API key 254 | is provided, it is still checked. 255 | 256 | """ 257 | 258 | app = make_app(bot, api_key, allow_without_key=allow_without_key) 259 | 260 | parser = argparse.ArgumentParser("FastAPI sample Poe bot server") 261 | parser.add_argument("-p", "--port", type=int, default=8080) 262 | args = parser.parse_args() 263 | port = args.port 264 | 265 | logger.info("Starting") 266 | import uvicorn.config 267 | 268 | log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) 269 | log_config["formatters"]["default"][ 270 | "fmt" 271 | ] = "%(asctime)s - %(levelname)s - %(message)s" 272 | uvicorn.run(app, host="0.0.0.0", port=port, log_config=log_config) 273 | 274 | 275 | if __name__ == "__main__": 276 | run(PoeBot()) 277 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Client for talking to other Poe bots through Poe's bot API. 4 | 5 | """ 6 | import asyncio 7 | import contextlib 8 | import json 9 | from dataclasses import dataclass, field 10 | from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, cast 11 | 12 | import httpx 13 | import httpx_sse 14 | 15 | from .types import ContentType, Identifier, QueryRequest, SettingsResponse 16 | 17 | API_VERSION = "1.0" 18 | MESSAGE_LENGTH_LIMIT = 10_000 19 | 20 | IDENTIFIER_LENGTH = 32 21 | MAX_EVENT_COUNT = 1000 22 | 23 | ErrorHandler = Callable[[Exception, str], None] 24 | 25 | 26 | class BotError(Exception): 27 | """Raised when there is an error communicating with the bot.""" 28 | 29 | 30 | class BotErrorNoRetry(BotError): 31 | """Subclass of BotError raised when we're not allowed to retry.""" 32 | 33 | 34 | class InvalidBotSettings(Exception): 35 | """Raised when a bot returns invalid settings.""" 36 | 37 | 38 | @dataclass 39 | class BotMessage: 40 | """Representation of a (possibly partial) response from a bot.""" 41 | 42 | text: str # text of the message so far 43 | raw_response: object 44 | full_prompt: Optional[str] 45 | request_id: Optional[str] = None 46 | is_suggested_reply: bool = False 47 | 48 | # "is_replace_response" - represents if this text 49 | # should completely replace the previous bot text. 50 | is_replace_response: bool = False 51 | 52 | 53 | @dataclass 54 | class MetaMessage(BotMessage): 55 | """Communicate 'meta' events from API bots.""" 56 | 57 | linkify: bool = True 58 | suggested_replies: bool = True 59 | content_type: ContentType = "text/markdown" 60 | 61 | 62 | def _safe_ellipsis(obj: object, limit: int) -> str: 63 | if not isinstance(obj, str): 64 | obj = repr(obj) 65 | if len(obj) > limit: 66 | obj = obj[: limit - 3] + "..." 67 | return obj 68 | 69 | 70 | @dataclass 71 | class _BotContext: 72 | endpoint: str 73 | api_key: str = field(repr=False) 74 | session: httpx.AsyncClient = field(repr=False) 75 | on_error: Optional[ErrorHandler] = field(default=None, repr=False) 76 | 77 | @property 78 | def headers(self) -> Dict[str, str]: 79 | return {"Accept": "application/json", "Authorization": f"Bearer {self.api_key}"} 80 | 81 | async def report_error( 82 | self, message: str, metadata: Optional[Dict[str, Any]] = None 83 | ) -> None: 84 | """Report an error to the bot server.""" 85 | if self.on_error is not None: 86 | long_message = ( 87 | f"Protocol bot error: {message} with metadata {metadata} " 88 | f"for endpoint {self.endpoint}" 89 | ) 90 | self.on_error(BotError(message), long_message) 91 | await self.session.post( 92 | self.endpoint, 93 | headers=self.headers, 94 | json={ 95 | "version": API_VERSION, 96 | "type": "report_error", 97 | "message": message, 98 | "metadata": metadata or {}, 99 | }, 100 | ) 101 | 102 | async def report_feedback( 103 | self, 104 | message_id: Identifier, 105 | user_id: Identifier, 106 | conversation_id: Identifier, 107 | feedback_type: str, 108 | ) -> None: 109 | """Report message feedback to the bot server.""" 110 | await self.session.post( 111 | self.endpoint, 112 | headers=self.headers, 113 | json={ 114 | "version": API_VERSION, 115 | "type": "report_feedback", 116 | "message_id": message_id, 117 | "user_id": user_id, 118 | "conversation_id": conversation_id, 119 | "feedback_type": feedback_type, 120 | }, 121 | ) 122 | 123 | async def fetch_settings(self) -> SettingsResponse: 124 | """Fetches settings from a Poe API Bot Endpoint.""" 125 | resp = await self.session.post( 126 | self.endpoint, 127 | headers=self.headers, 128 | json={"version": API_VERSION, "type": "settings"}, 129 | ) 130 | return resp.json() 131 | 132 | async def perform_query_request( 133 | self, request: QueryRequest 134 | ) -> AsyncGenerator[BotMessage, None]: 135 | chunks: List[str] = [] 136 | message_id = request.message_id 137 | event_count = 0 138 | error_reported = False 139 | async with httpx_sse.aconnect_sse( 140 | self.session, 141 | "POST", 142 | self.endpoint, 143 | headers=self.headers, 144 | json=request.dict(), 145 | ) as event_source: 146 | async for event in event_source.aiter_sse(): 147 | event_count += 1 148 | if event_count > MAX_EVENT_COUNT: 149 | await self.report_error( 150 | "Bot returned too many events", {"message_id": message_id} 151 | ) 152 | raise BotErrorNoRetry("Bot returned too many events") 153 | if event.event == "done": 154 | # Don't send a report if we already told the bot about some other mistake. 155 | if not chunks and not error_reported: 156 | await self.report_error( 157 | "Bot returned no text in response", 158 | {"message_id": message_id}, 159 | ) 160 | return 161 | elif event.event == "text": 162 | text = await self._get_single_json_field( 163 | event.data, "text", message_id 164 | ) 165 | elif event.event == "replace_response": 166 | text = await self._get_single_json_field( 167 | event.data, "replace_response", message_id 168 | ) 169 | chunks.clear() 170 | elif event.event == "suggested_reply": 171 | text = await self._get_single_json_field( 172 | event.data, "suggested_reply", message_id 173 | ) 174 | yield BotMessage( 175 | text=text, 176 | raw_response={"type": event.event, "text": event.data}, 177 | full_prompt=repr(request), 178 | is_suggested_reply=True, 179 | ) 180 | continue 181 | elif event.event == "meta": 182 | if event_count != 1: 183 | # spec says a meta event that is not the first event is ignored 184 | continue 185 | data = await self._load_json_dict(event.data, "meta", message_id) 186 | linkify = data.get("linkify", False) 187 | if not isinstance(linkify, bool): 188 | await self.report_error( 189 | "Invalid linkify value in 'meta' event", 190 | {"message_id": message_id, "linkify": linkify}, 191 | ) 192 | error_reported = True 193 | continue 194 | send_suggested_replies = data.get("suggested_replies", False) 195 | if not isinstance(send_suggested_replies, bool): 196 | await self.report_error( 197 | "Invalid suggested_replies value in 'meta' event", 198 | { 199 | "message_id": message_id, 200 | "suggested_replies": send_suggested_replies, 201 | }, 202 | ) 203 | error_reported = True 204 | continue 205 | content_type = data.get("content_type", "text/markdown") 206 | if not isinstance(content_type, str): 207 | await self.report_error( 208 | "Invalid content_type value in 'meta' event", 209 | {"message_id": message_id, "content_type": content_type}, 210 | ) 211 | error_reported = True 212 | continue 213 | yield MetaMessage( 214 | "", 215 | data, 216 | full_prompt=repr(request), 217 | linkify=linkify, 218 | suggested_replies=send_suggested_replies, 219 | content_type=cast(ContentType, content_type), 220 | ) 221 | continue 222 | elif event.event == "error": 223 | data = await self._load_json_dict(event.data, "error", message_id) 224 | if data.get("allow_retry", True): 225 | raise BotError(event.data) 226 | else: 227 | raise BotErrorNoRetry(event.data) 228 | elif event.event == "ping": 229 | # Not formally part of the spec, but FastAPI sends this; let's ignore it 230 | # instead of sending error reports. 231 | continue 232 | else: 233 | # Truncate the type and message in case it's huge. 234 | await self.report_error( 235 | f"Unknown event type: {_safe_ellipsis(event.event, 100)}", 236 | { 237 | "event_data": _safe_ellipsis(event.data, 500), 238 | "message_id": message_id, 239 | }, 240 | ) 241 | error_reported = True 242 | continue 243 | chunks.append(text) 244 | total_length = sum(len(chunk) for chunk in chunks) 245 | if total_length > MESSAGE_LENGTH_LIMIT: 246 | await self.report_error( 247 | "Bot returned too much text", 248 | {"message_id": message_id, "response_length": total_length}, 249 | ) 250 | raise BotErrorNoRetry("Bot returned too much text") 251 | yield BotMessage( 252 | text=text, 253 | raw_response={"type": event.event, "text": event.data}, 254 | full_prompt=repr(request), 255 | is_replace_response=(event.event == "replace_response"), 256 | ) 257 | await self.report_error( 258 | "Bot exited without sending 'done' event", {"message_id": message_id} 259 | ) 260 | 261 | async def _get_single_json_field( 262 | self, data: str, context: str, message_id: Identifier, field: str = "text" 263 | ) -> str: 264 | data_dict = await self._load_json_dict(data, context, message_id) 265 | text = data_dict[field] 266 | if not isinstance(text, str): 267 | await self.report_error( 268 | f"Expected string in '{field}' field for '{context}' event", 269 | {"data": data_dict, "message_id": message_id}, 270 | ) 271 | raise BotErrorNoRetry(f"Expected string in '{context}' event") 272 | return text 273 | 274 | async def _load_json_dict( 275 | self, data: str, context: str, message_id: Identifier 276 | ) -> Dict[str, object]: 277 | try: 278 | parsed = json.loads(data) 279 | except json.JSONDecodeError: 280 | await self.report_error( 281 | f"Invalid JSON in {context!r} event", 282 | {"data": data, "message_id": message_id}, 283 | ) 284 | # If they are returning invalid JSON, retrying immediately probably won't help 285 | raise BotErrorNoRetry(f"Invalid JSON in {context!r} event") 286 | if not isinstance(parsed, dict): 287 | await self.report_error( 288 | f"Expected JSON dict in {context!r} event", 289 | {"data": data, "message_id": message_id}, 290 | ) 291 | raise BotError(f"Expected JSON dict in {context!r} event") 292 | return cast(Dict[str, object], parsed) 293 | 294 | 295 | def _default_error_handler(e: Exception, msg: str) -> None: 296 | print("Error in Poe API Bot:", msg, e) 297 | 298 | 299 | async def stream_request( 300 | request: QueryRequest, 301 | bot_name: str, 302 | api_key: str, 303 | *, 304 | session: Optional[httpx.AsyncClient] = None, 305 | on_error: ErrorHandler = _default_error_handler, 306 | num_tries: int = 2, 307 | retry_sleep_time: float = 0.5, 308 | base_url: str = "https://api.poe.com/bot/", 309 | ) -> AsyncGenerator[BotMessage, None]: 310 | """Streams BotMessages from an API bot.""" 311 | async with contextlib.AsyncExitStack() as stack: 312 | if session is None: 313 | session = await stack.enter_async_context(httpx.AsyncClient()) 314 | url = f"{base_url}{bot_name}" 315 | ctx = _BotContext( 316 | endpoint=url, api_key=api_key, session=session, on_error=on_error 317 | ) 318 | got_response = False 319 | for i in range(num_tries): 320 | try: 321 | async for message in ctx.perform_query_request(request): 322 | got_response = True 323 | yield message 324 | break 325 | except BotErrorNoRetry: 326 | raise 327 | except Exception as e: 328 | on_error(e, f"Bot request to {bot_name} failed on try {i}") 329 | if got_response or i == num_tries - 1: 330 | raise BotError(f"Error communicating with bot {bot_name}") from e 331 | await asyncio.sleep(retry_sleep_time) 332 | 333 | 334 | async def get_final_response(request: QueryRequest, bot_name: str, api_key: str) -> str: 335 | """Gets the final response from an API bot.""" 336 | chunks: List[str] = [] 337 | async for message in stream_request(request, bot_name, api_key): 338 | if isinstance(message, MetaMessage): 339 | continue 340 | if message.is_suggested_reply: 341 | continue 342 | if message.is_replace_response: 343 | chunks.clear() 344 | chunks.append(message.text) 345 | if not chunks: 346 | raise BotError(f"Bot {bot_name} sent no response") 347 | return "".join(chunks) 348 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/samples/echo.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Sample bot that echoes back messages. 4 | 5 | """ 6 | from __future__ import annotations 7 | 8 | from typing import AsyncIterable 9 | 10 | from sse_starlette.sse import ServerSentEvent 11 | 12 | from fastapi_poe import PoeBot, run 13 | from fastapi_poe.types import QueryRequest 14 | 15 | 16 | class EchoBot(PoeBot): 17 | async def get_response(self, query: QueryRequest) -> AsyncIterable[ServerSentEvent]: 18 | last_message = query.query[-1].content 19 | yield self.text_event(last_message) 20 | 21 | 22 | if __name__ == "__main__": 23 | run(EchoBot()) 24 | -------------------------------------------------------------------------------- /fastapi_poe/src/fastapi_poe/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | from pydantic import BaseModel, Field 4 | from typing_extensions import Literal, TypeAlias 5 | 6 | Identifier: TypeAlias = str 7 | FeedbackType: TypeAlias = Literal["like", "dislike"] 8 | ContentType: TypeAlias = Literal["text/markdown", "text/plain"] 9 | 10 | 11 | class MessageFeedback(BaseModel): 12 | """Feedback for a message as used in the Poe protocol.""" 13 | 14 | type: FeedbackType 15 | reason: Optional[str] 16 | 17 | 18 | class ProtocolMessage(BaseModel): 19 | """A message as used in the Poe protocol.""" 20 | 21 | role: Literal["system", "user", "bot"] 22 | content: str 23 | content_type: ContentType = "text/markdown" 24 | timestamp: int = 0 25 | message_id: str = "" 26 | feedback: List[MessageFeedback] = Field(default_factory=list) 27 | 28 | 29 | class BaseRequest(BaseModel): 30 | """Common data for all requests.""" 31 | 32 | version: str 33 | type: Literal["query", "settings", "report_feedback", "report_error"] 34 | 35 | 36 | class QueryRequest(BaseRequest): 37 | """Request parameters for a query request.""" 38 | 39 | query: List[ProtocolMessage] 40 | user_id: Identifier 41 | conversation_id: Identifier 42 | message_id: Identifier 43 | api_key: str = "" 44 | 45 | 46 | class SettingsRequest(BaseRequest): 47 | """Request parameters for a settings request.""" 48 | 49 | 50 | class ReportFeedbackRequest(BaseRequest): 51 | """Request parameters for a report_feedback request.""" 52 | 53 | message_id: Identifier 54 | user_id: Identifier 55 | conversation_id: Identifier 56 | feedback_type: FeedbackType 57 | 58 | 59 | class ReportErrorRequest(BaseRequest): 60 | """Request parameters for a report_error request.""" 61 | 62 | message: str 63 | metadata: Dict[str, Any] 64 | 65 | 66 | class SettingsResponse(BaseModel): 67 | context_clear_window_secs: Optional[int] = None 68 | allow_user_context_clear: bool = True 69 | -------------------------------------------------------------------------------- /images/chat-sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/images/chat-sample.png -------------------------------------------------------------------------------- /langchain_poe/README.md: -------------------------------------------------------------------------------- 1 | # LangChain-Poe 2 | 3 | A demonstration bot built using LangChain. 4 | 5 | To use it: 6 | 7 | - Install the bot (for now: clone this repo, run `pip install .` in this dirctory) 8 | - Get an OpenAI API key 9 | - Run `OPENAI_API_KEY=your-api-key python3 -m langchain_poe` 10 | - Make your server publicly accessible (e.g., using `ngrok`) 11 | - Connect it to Poe 12 | -------------------------------------------------------------------------------- /langchain_poe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "langchain_poe" 7 | version = "0.0.13" 8 | authors = [ 9 | { name="Jelle Zijlstra", email="jelle@quora.com" }, 10 | ] 11 | description = "A demonstration of a Poe bot built using LangChain" 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "fastapi_poe", 21 | "langchain", 22 | "openai", 23 | ] 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/quora/poe-protocol" 27 | 28 | [tool.pyright] 29 | pythonVersion = "3.7" 30 | -------------------------------------------------------------------------------- /langchain_poe/src/langchain_poe/__init__.py: -------------------------------------------------------------------------------- 1 | from .poe import LangChainCatBot as LangChainCatBot # noqa: F401 2 | -------------------------------------------------------------------------------- /langchain_poe/src/langchain_poe/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from fastapi_poe import run 4 | 5 | from .poe import LangChainCatBot 6 | 7 | if __name__ == "__main__": 8 | run(LangChainCatBot(os.environ["OPENAI_API_KEY"])) 9 | -------------------------------------------------------------------------------- /langchain_poe/src/langchain_poe/poe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from typing import AsyncIterable 4 | 5 | from langchain.callbacks import AsyncIteratorCallbackHandler 6 | from langchain.callbacks.manager import AsyncCallbackManager 7 | from langchain.chat_models import ChatOpenAI 8 | from langchain.schema import AIMessage, HumanMessage, SystemMessage 9 | from sse_starlette.sse import ServerSentEvent 10 | 11 | from fastapi_poe import PoeBot 12 | from fastapi_poe.types import QueryRequest 13 | 14 | template = """You are an automated cat. 15 | 16 | You can assist with a wide range of tasks, but you always respond in the style of a cat, 17 | and you are easily distracted.""" 18 | 19 | 20 | @dataclass 21 | class LangChainCatBot(PoeBot): 22 | openai_key: str 23 | 24 | async def get_response(self, query: QueryRequest) -> AsyncIterable[ServerSentEvent]: 25 | messages = [SystemMessage(content=template)] 26 | for message in query.query: 27 | if message.role == "bot": 28 | messages.append(AIMessage(content=message.content)) 29 | elif message.role == "user": 30 | messages.append(HumanMessage(content=message.content)) 31 | handler = AsyncIteratorCallbackHandler() 32 | chat = ChatOpenAI( 33 | openai_api_key=self.openai_key, 34 | streaming=True, 35 | callback_manager=AsyncCallbackManager([handler]), 36 | temperature=0, 37 | ) 38 | asyncio.create_task(chat.agenerate([messages])) 39 | async for token in handler.aiter(): 40 | yield self.text_event(token) 41 | -------------------------------------------------------------------------------- /llama_poe/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: start, format, try 2 | 3 | format: 4 | poetry run black . 5 | poetry run isort . 6 | 7 | try: 8 | curl -X 'POST' \ 9 | 'http://0.0.0.0:8080/' \ 10 | -H 'accept: application/json' \ 11 | -H 'Content-Type: application/json' \ 12 | -H 'Authorization: Bearer ' \ 13 | -d '{ \ 14 | "version": "1.0", \ 15 | "type": "query", \ 16 | "query": [ \ 17 | { \ 18 | "message_id": "1", \ 19 | "role": "system", \ 20 | "content": "You are a helpful assistant.", \ 21 | "content_type": "text/markdown", \ 22 | "timestamp": 1678299819427621, \ 23 | "feedback": [] \ 24 | }, \ 25 | { \ 26 | "message_id": "2", \ 27 | "role": "user", \ 28 | "content": "What is the capital of Nepal?", \ 29 | "content_type": "text/markdown", \ 30 | "timestamp": 1678299819427621, \ 31 | "feedback": [] \ 32 | } \ 33 | ], \ 34 | "user_id": "u-1234abcd5678efgh", \ 35 | "conversation_id": "c-jklm9012nopq3456", \ 36 | "message_id": "2" \ 37 | }' -N 38 | -------------------------------------------------------------------------------- /llama_poe/README.md: -------------------------------------------------------------------------------- 1 | # Poe Knowledge Bot with LlamaIndex 2 | 3 | A knowledge-augmented Poe bot powered by 4 | [LlamaIndex](https://gpt-index.readthedocs.io/en/latest/) and FastAPI. 5 | 6 | Easily ingest and chat with your own data as a knowledge base! 7 | 8 | ## Quick Start 9 | 10 | Follow these steps to quickly setup and run the LlamaIndex bot for Poe: 11 | 12 | ### Setup Environment 13 | 14 | 1. Install poetry: `pip install poetry` 15 | 2. Install app dependencies: `poetry install` 16 | 3. Setup environment variables 17 | 18 | | Name | Required | Description | 19 | | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | 20 | | `POE_API_KEY` | Yes | This is a secret token that you need to authenticate Poe requests to the API. You can generate this from https://poe.com/create_bot?api=1. | 21 | | `OPENAI_API_KEY` | Yes | This is your OpenAI API key that LlamaIndex needs to call OpenAI services. You can get an API key by creating an account on [OpenAI](https://openai.com/). | 22 | 23 | ### Run API Server 24 | 25 | - Run the API locally: `poetry run start` 26 | 27 | ```console 28 | INFO:poe_api.llama_handler:Creating new index 29 | INFO:poe_api.llama_handler:Loading data from data/ 30 | INFO:llama_index.token_counter.token_counter:> [insert] Total LLM token usage: 0 tokens 31 | INFO:llama_index.token_counter.token_counter:> [insert] Total embedding token usage: 19274 tokens 32 | 2023-04-17 15:24:05,159 - INFO - Application startup complete. 33 | ``` 34 | 35 | - Make the API publicly available with [ngrok](https://ngrok.com/): in a different 36 | terminal, run `ngrok http 8080` 37 | 38 | ### Connect Poe to your Bot 39 | 40 | - Create your bot at https://poe.com/create_bot?api=1 41 | - Interact with your bot at https://poe.com/ 42 | 43 | ## Test Your LlamaIndex Bot 44 | 45 | To quickly verify if your bot is up and running, go to the Swagger UI at 46 | http://localhost:8080/docs, authenticate with your `POE_API_KEY` and issue a query 47 | (satisfying the 48 | [Poe Protocol](https://github.com/poe-platform/poe-protocol/blob/main/docs/spec.md)) 49 | 50 | Alternatively, to use a sample query, replace `` in 51 | `Makefile` with your `POE_API_KEY`, then run: 52 | 53 | ```console 54 | make try 55 | ``` 56 | 57 | ## Customize Your LlamaIndex Bot 58 | 59 | By default, we ingest documents under `data/` and index them with a 60 | `GPTSimpleVectorIndex`. 61 | 62 | You can configure the default behavior via environment variables: 63 | 64 | | Name | Required | Description | 65 | | ------------------ | -------- | --------------------------------------------------------------- | 66 | | `LLAMA_LOAD_DATA` | Optional | Whether to ingest documents in `DATA_DIR`.Defaults to `True` | 67 | | `LLAMA_DATA_DIR` | Optional | Directory to ingest initial documents from. Defaults to `data/` | 68 | | `LLAMA_INDEX_TYPE` | Optional | Index type (see below for details). Defaults to `simple_dict` | 69 | | `INDEX_JSON_PATH` | Optional | Path to saved Index json file. `save/index.json` | 70 | 71 | **Different Index Types** By default, we use a `GPTSimpleVectorIndex` to store document 72 | chunks in memory, and retrieve top-k nodes by embedding similarity. Different index 73 | types are optimized for different data and query use-cases. See this guide on 74 | [How Each Index Works](https://gpt-index.readthedocs.io/en/latest/guides/primer/index_guide.html) 75 | to learn more. You can configure the index type via the `LLAMA_INDEX_TYPE`, see 76 | [here](https://gpt-index.readthedocs.io/en/latest/reference/indices/composability_query.html#gpt_index.data_structs.struct_type.IndexStructType) 77 | for the full list of accepted index type identifiers. 78 | 79 | Read more details on [readthedocs](https://gpt-index.readthedocs.io/en/latest/), and 80 | engage with the community on [discord](https://discord.com/invite/dGcwcsnxhU). 81 | 82 | ## Ingesting Data 83 | 84 | LlamaIndex bot for Poe also exposes an API for ingesting additional data by `POST` to 85 | `/add_document` endpoint. 86 | 87 | You can use the Swagger UI to quickly experiment with ingesting additional documents: 88 | 89 | - Locally: `http://localhost:8080/docs` 90 | - Publiclly via `ngrok`: `https://.ngrok-free.app/docs` 91 | -------------------------------------------------------------------------------- /llama_poe/data/apps.md: -------------------------------------------------------------------------------- 1 | # Integrations into LLM Applications 2 | 3 | LlamaIndex modules provide plug and play data loaders, data structures, and query 4 | interfaces. They can be used in your downstream LLM Application. Some of these 5 | applications are described below. 6 | 7 | ### Chatbots 8 | 9 | Chatbots are an incredibly popular use case for LLM's. LlamaIndex gives you the tools to 10 | build Knowledge-augmented chatbots and agents. 11 | 12 | Relevant Resources: 13 | 14 | - [Building a Chatbot](/guides/tutorials/building_a_chatbot.md) 15 | - [Using with a LangChain Agent](/how_to/integrations/using_with_langchain.md) 16 | 17 | ### Full-Stack Web Application 18 | 19 | LlamaIndex can be integrated into a downstream full-stack web application. It can be 20 | used in a backend server (such as Flask), packaged into a Docker container, and/or 21 | directly used in a framework such as Streamlit. 22 | 23 | We provide tutorials and resources to help you get started in this area. 24 | 25 | Relevant Resources: 26 | 27 | - [Fullstack Application Guide](/guides/tutorials/fullstack_app_guide.md) 28 | - [LlamaIndex Starter Pack](https://github.com/logan-markewich/llama_index_starter_pack) 29 | -------------------------------------------------------------------------------- /llama_poe/data/paul_graham_essay.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | What I Worked On 4 | 5 | February 2021 6 | 7 | Before college the two main things I worked on, outside of school, were writing and programming. I didn't write essays. I wrote what beginning writers were supposed to write then, and probably still are: short stories. My stories were awful. They had hardly any plot, just characters with strong feelings, which I imagined made them deep. 8 | 9 | The first programs I tried writing were on the IBM 1401 that our school district used for what was then called "data processing." This was in 9th grade, so I was 13 or 14. The school district's 1401 happened to be in the basement of our junior high school, and my friend Rich Draves and I got permission to use it. It was like a mini Bond villain's lair down there, with all these alien-looking machines — CPU, disk drives, printer, card reader — sitting up on a raised floor under bright fluorescent lights. 10 | 11 | The language we used was an early version of Fortran. You had to type programs on punch cards, then stack them in the card reader and press a button to load the program into memory and run it. The result would ordinarily be to print something on the spectacularly loud printer. 12 | 13 | I was puzzled by the 1401. I couldn't figure out what to do with it. And in retrospect there's not much I could have done with it. The only form of input to programs was data stored on punched cards, and I didn't have any data stored on punched cards. The only other option was to do things that didn't rely on any input, like calculate approximations of pi, but I didn't know enough math to do anything interesting of that type. So I'm not surprised I can't remember any programs I wrote, because they can't have done much. My clearest memory is of the moment I learned it was possible for programs not to terminate, when one of mine didn't. On a machine without time-sharing, this was a social as well as a technical error, as the data center manager's expression made clear. 14 | 15 | With microcomputers, everything changed. Now you could have a computer sitting right in front of you, on a desk, that could respond to your keystrokes as it was running instead of just churning through a stack of punch cards and then stopping. [1] 16 | 17 | The first of my friends to get a microcomputer built it himself. It was sold as a kit by Heathkit. I remember vividly how impressed and envious I felt watching him sitting in front of it, typing programs right into the computer. 18 | 19 | Computers were expensive in those days and it took me years of nagging before I convinced my father to buy one, a TRS-80, in about 1980. The gold standard then was the Apple II, but a TRS-80 was good enough. This was when I really started programming. I wrote simple games, a program to predict how high my model rockets would fly, and a word processor that my father used to write at least one book. There was only room in memory for about 2 pages of text, so he'd write 2 pages at a time and then print them out, but it was a lot better than a typewriter. 20 | 21 | Though I liked programming, I didn't plan to study it in college. In college I was going to study philosophy, which sounded much more powerful. It seemed, to my naive high school self, to be the study of the ultimate truths, compared to which the things studied in other fields would be mere domain knowledge. What I discovered when I got to college was that the other fields took up so much of the space of ideas that there wasn't much left for these supposed ultimate truths. All that seemed left for philosophy were edge cases that people in other fields felt could safely be ignored. 22 | 23 | I couldn't have put this into words when I was 18. All I knew at the time was that I kept taking philosophy courses and they kept being boring. So I decided to switch to AI. 24 | 25 | AI was in the air in the mid 1980s, but there were two things especially that made me want to work on it: a novel by Heinlein called The Moon is a Harsh Mistress, which featured an intelligent computer called Mike, and a PBS documentary that showed Terry Winograd using SHRDLU. I haven't tried rereading The Moon is a Harsh Mistress, so I don't know how well it has aged, but when I read it I was drawn entirely into its world. It seemed only a matter of time before we'd have Mike, and when I saw Winograd using SHRDLU, it seemed like that time would be a few years at most. All you had to do was teach SHRDLU more words. 26 | 27 | There weren't any classes in AI at Cornell then, not even graduate classes, so I started trying to teach myself. Which meant learning Lisp, since in those days Lisp was regarded as the language of AI. The commonly used programming languages then were pretty primitive, and programmers' ideas correspondingly so. The default language at Cornell was a Pascal-like language called PL/I, and the situation was similar elsewhere. Learning Lisp expanded my concept of a program so fast that it was years before I started to have a sense of where the new limits were. This was more like it; this was what I had expected college to do. It wasn't happening in a class, like it was supposed to, but that was ok. For the next couple years I was on a roll. I knew what I was going to do. 28 | 29 | For my undergraduate thesis, I reverse-engineered SHRDLU. My God did I love working on that program. It was a pleasing bit of code, but what made it even more exciting was my belief — hard to imagine now, but not unique in 1985 — that it was already climbing the lower slopes of intelligence. 30 | 31 | I had gotten into a program at Cornell that didn't make you choose a major. You could take whatever classes you liked, and choose whatever you liked to put on your degree. I of course chose "Artificial Intelligence." When I got the actual physical diploma, I was dismayed to find that the quotes had been included, which made them read as scare-quotes. At the time this bothered me, but now it seems amusingly accurate, for reasons I was about to discover. 32 | 33 | I applied to 3 grad schools: MIT and Yale, which were renowned for AI at the time, and Harvard, which I'd visited because Rich Draves went there, and was also home to Bill Woods, who'd invented the type of parser I used in my SHRDLU clone. Only Harvard accepted me, so that was where I went. 34 | 35 | I don't remember the moment it happened, or if there even was a specific moment, but during the first year of grad school I realized that AI, as practiced at the time, was a hoax. By which I mean the sort of AI in which a program that's told "the dog is sitting on the chair" translates this into some formal representation and adds it to the list of things it knows. 36 | 37 | What these programs really showed was that there's a subset of natural language that's a formal language. But a very proper subset. It was clear that there was an unbridgeable gap between what they could do and actually understanding natural language. It was not, in fact, simply a matter of teaching SHRDLU more words. That whole way of doing AI, with explicit data structures representing concepts, was not going to work. Its brokenness did, as so often happens, generate a lot of opportunities to write papers about various band-aids that could be applied to it, but it was never going to get us Mike. 38 | 39 | So I looked around to see what I could salvage from the wreckage of my plans, and there was Lisp. I knew from experience that Lisp was interesting for its own sake and not just for its association with AI, even though that was the main reason people cared about it at the time. So I decided to focus on Lisp. In fact, I decided to write a book about Lisp hacking. It's scary to think how little I knew about Lisp hacking when I started writing that book. But there's nothing like writing a book about something to help you learn it. The book, On Lisp, wasn't published till 1993, but I wrote much of it in grad school. 40 | 41 | Computer Science is an uneasy alliance between two halves, theory and systems. The theory people prove things, and the systems people build things. I wanted to build things. I had plenty of respect for theory — indeed, a sneaking suspicion that it was the more admirable of the two halves — but building things seemed so much more exciting. 42 | 43 | The problem with systems work, though, was that it didn't last. Any program you wrote today, no matter how good, would be obsolete in a couple decades at best. People might mention your software in footnotes, but no one would actually use it. And indeed, it would seem very feeble work. Only people with a sense of the history of the field would even realize that, in its time, it had been good. 44 | 45 | There were some surplus Xerox Dandelions floating around the computer lab at one point. Anyone who wanted one to play around with could have one. I was briefly tempted, but they were so slow by present standards; what was the point? No one else wanted one either, so off they went. That was what happened to systems work. 46 | 47 | I wanted not just to build things, but to build things that would last. 48 | 49 | In this dissatisfied state I went in 1988 to visit Rich Draves at CMU, where he was in grad school. One day I went to visit the Carnegie Institute, where I'd spent a lot of time as a kid. While looking at a painting there I realized something that might seem obvious, but was a big surprise to me. There, right on the wall, was something you could make that would last. Paintings didn't become obsolete. Some of the best ones were hundreds of years old. 50 | 51 | And moreover this was something you could make a living doing. Not as easily as you could by writing software, of course, but I thought if you were really industrious and lived really cheaply, it had to be possible to make enough to survive. And as an artist you could be truly independent. You wouldn't have a boss, or even need to get research funding. 52 | 53 | I had always liked looking at paintings. Could I make them? I had no idea. I'd never imagined it was even possible. I knew intellectually that people made art — that it didn't just appear spontaneously — but it was as if the people who made it were a different species. They either lived long ago or were mysterious geniuses doing strange things in profiles in Life magazine. The idea of actually being able to make art, to put that verb before that noun, seemed almost miraculous. 54 | 55 | That fall I started taking art classes at Harvard. Grad students could take classes in any department, and my advisor, Tom Cheatham, was very easy going. If he even knew about the strange classes I was taking, he never said anything. 56 | 57 | So now I was in a PhD program in computer science, yet planning to be an artist, yet also genuinely in love with Lisp hacking and working away at On Lisp. In other words, like many a grad student, I was working energetically on multiple projects that were not my thesis. 58 | 59 | I didn't see a way out of this situation. I didn't want to drop out of grad school, but how else was I going to get out? I remember when my friend Robert Morris got kicked out of Cornell for writing the internet worm of 1988, I was envious that he'd found such a spectacular way to get out of grad school. 60 | 61 | Then one day in April 1990 a crack appeared in the wall. I ran into professor Cheatham and he asked if I was far enough along to graduate that June. I didn't have a word of my dissertation written, but in what must have been the quickest bit of thinking in my life, I decided to take a shot at writing one in the 5 weeks or so that remained before the deadline, reusing parts of On Lisp where I could, and I was able to respond, with no perceptible delay "Yes, I think so. I'll give you something to read in a few days." 62 | 63 | I picked applications of continuations as the topic. In retrospect I should have written about macros and embedded languages. There's a whole world there that's barely been explored. But all I wanted was to get out of grad school, and my rapidly written dissertation sufficed, just barely. 64 | 65 | Meanwhile I was applying to art schools. I applied to two: RISD in the US, and the Accademia di Belli Arti in Florence, which, because it was the oldest art school, I imagined would be good. RISD accepted me, and I never heard back from the Accademia, so off to Providence I went. 66 | 67 | I'd applied for the BFA program at RISD, which meant in effect that I had to go to college again. This was not as strange as it sounds, because I was only 25, and art schools are full of people of different ages. RISD counted me as a transfer sophomore and said I had to do the foundation that summer. The foundation means the classes that everyone has to take in fundamental subjects like drawing, color, and design. 68 | 69 | Toward the end of the summer I got a big surprise: a letter from the Accademia, which had been delayed because they'd sent it to Cambridge England instead of Cambridge Massachusetts, inviting me to take the entrance exam in Florence that fall. This was now only weeks away. My nice landlady let me leave my stuff in her attic. I had some money saved from consulting work I'd done in grad school; there was probably enough to last a year if I lived cheaply. Now all I had to do was learn Italian. 70 | 71 | Only stranieri (foreigners) had to take this entrance exam. In retrospect it may well have been a way of excluding them, because there were so many stranieri attracted by the idea of studying art in Florence that the Italian students would otherwise have been outnumbered. I was in decent shape at painting and drawing from the RISD foundation that summer, but I still don't know how I managed to pass the written exam. I remember that I answered the essay question by writing about Cezanne, and that I cranked up the intellectual level as high as I could to make the most of my limited vocabulary. [2] 72 | 73 | I'm only up to age 25 and already there are such conspicuous patterns. Here I was, yet again about to attend some august institution in the hopes of learning about some prestigious subject, and yet again about to be disappointed. The students and faculty in the painting department at the Accademia were the nicest people you could imagine, but they had long since arrived at an arrangement whereby the students wouldn't require the faculty to teach anything, and in return the faculty wouldn't require the students to learn anything. And at the same time all involved would adhere outwardly to the conventions of a 19th century atelier. We actually had one of those little stoves, fed with kindling, that you see in 19th century studio paintings, and a nude model sitting as close to it as possible without getting burned. Except hardly anyone else painted her besides me. The rest of the students spent their time chatting or occasionally trying to imitate things they'd seen in American art magazines. 74 | 75 | Our model turned out to live just down the street from me. She made a living from a combination of modelling and making fakes for a local antique dealer. She'd copy an obscure old painting out of a book, and then he'd take the copy and maltreat it to make it look old. [3] 76 | 77 | While I was a student at the Accademia I started painting still lives in my bedroom at night. These paintings were tiny, because the room was, and because I painted them on leftover scraps of canvas, which was all I could afford at the time. Painting still lives is different from painting people, because the subject, as its name suggests, can't move. People can't sit for more than about 15 minutes at a time, and when they do they don't sit very still. So the traditional m.o. for painting people is to know how to paint a generic person, which you then modify to match the specific person you're painting. Whereas a still life you can, if you want, copy pixel by pixel from what you're seeing. You don't want to stop there, of course, or you get merely photographic accuracy, and what makes a still life interesting is that it's been through a head. You want to emphasize the visual cues that tell you, for example, that the reason the color changes suddenly at a certain point is that it's the edge of an object. By subtly emphasizing such things you can make paintings that are more realistic than photographs not just in some metaphorical sense, but in the strict information-theoretic sense. [4] 78 | 79 | I liked painting still lives because I was curious about what I was seeing. In everyday life, we aren't consciously aware of much we're seeing. Most visual perception is handled by low-level processes that merely tell your brain "that's a water droplet" without telling you details like where the lightest and darkest points are, or "that's a bush" without telling you the shape and position of every leaf. This is a feature of brains, not a bug. In everyday life it would be distracting to notice every leaf on every bush. But when you have to paint something, you have to look more closely, and when you do there's a lot to see. You can still be noticing new things after days of trying to paint something people usually take for granted, just as you can after days of trying to write an essay about something people usually take for granted. 80 | 81 | This is not the only way to paint. I'm not 100% sure it's even a good way to paint. But it seemed a good enough bet to be worth trying. 82 | 83 | Our teacher, professor Ulivi, was a nice guy. He could see I worked hard, and gave me a good grade, which he wrote down in a sort of passport each student had. But the Accademia wasn't teaching me anything except Italian, and my money was running out, so at the end of the first year I went back to the US. 84 | 85 | I wanted to go back to RISD, but I was now broke and RISD was very expensive, so I decided to get a job for a year and then return to RISD the next fall. I got one at a company called Interleaf, which made software for creating documents. You mean like Microsoft Word? Exactly. That was how I learned that low end software tends to eat high end software. But Interleaf still had a few years to live yet. [5] 86 | 87 | Interleaf had done something pretty bold. Inspired by Emacs, they'd added a scripting language, and even made the scripting language a dialect of Lisp. Now they wanted a Lisp hacker to write things in it. This was the closest thing I've had to a normal job, and I hereby apologize to my boss and coworkers, because I was a bad employee. Their Lisp was the thinnest icing on a giant C cake, and since I didn't know C and didn't want to learn it, I never understood most of the software. Plus I was terribly irresponsible. This was back when a programming job meant showing up every day during certain working hours. That seemed unnatural to me, and on this point the rest of the world is coming around to my way of thinking, but at the time it caused a lot of friction. Toward the end of the year I spent much of my time surreptitiously working on On Lisp, which I had by this time gotten a contract to publish. 88 | 89 | The good part was that I got paid huge amounts of money, especially by art student standards. In Florence, after paying my part of the rent, my budget for everything else had been $7 a day. Now I was getting paid more than 4 times that every hour, even when I was just sitting in a meeting. By living cheaply I not only managed to save enough to go back to RISD, but also paid off my college loans. 90 | 91 | I learned some useful things at Interleaf, though they were mostly about what not to do. I learned that it's better for technology companies to be run by product people than sales people (though sales is a real skill and people who are good at it are really good at it), that it leads to bugs when code is edited by too many people, that cheap office space is no bargain if it's depressing, that planned meetings are inferior to corridor conversations, that big, bureaucratic customers are a dangerous source of money, and that there's not much overlap between conventional office hours and the optimal time for hacking, or conventional offices and the optimal place for it. 92 | 93 | But the most important thing I learned, and which I used in both Viaweb and Y Combinator, is that the low end eats the high end: that it's good to be the "entry level" option, even though that will be less prestigious, because if you're not, someone else will be, and will squash you against the ceiling. Which in turn means that prestige is a danger sign. 94 | 95 | When I left to go back to RISD the next fall, I arranged to do freelance work for the group that did projects for customers, and this was how I survived for the next several years. When I came back to visit for a project later on, someone told me about a new thing called HTML, which was, as he described it, a derivative of SGML. Markup language enthusiasts were an occupational hazard at Interleaf and I ignored him, but this HTML thing later became a big part of my life. 96 | 97 | In the fall of 1992 I moved back to Providence to continue at RISD. The foundation had merely been intro stuff, and the Accademia had been a (very civilized) joke. Now I was going to see what real art school was like. But alas it was more like the Accademia than not. Better organized, certainly, and a lot more expensive, but it was now becoming clear that art school did not bear the same relationship to art that medical school bore to medicine. At least not the painting department. The textile department, which my next door neighbor belonged to, seemed to be pretty rigorous. No doubt illustration and architecture were too. But painting was post-rigorous. Painting students were supposed to express themselves, which to the more worldly ones meant to try to cook up some sort of distinctive signature style. 98 | 99 | A signature style is the visual equivalent of what in show business is known as a "schtick": something that immediately identifies the work as yours and no one else's. For example, when you see a painting that looks like a certain kind of cartoon, you know it's by Roy Lichtenstein. So if you see a big painting of this type hanging in the apartment of a hedge fund manager, you know he paid millions of dollars for it. That's not always why artists have a signature style, but it's usually why buyers pay a lot for such work. [6] 100 | 101 | There were plenty of earnest students too: kids who "could draw" in high school, and now had come to what was supposed to be the best art school in the country, to learn to draw even better. They tended to be confused and demoralized by what they found at RISD, but they kept going, because painting was what they did. I was not one of the kids who could draw in high school, but at RISD I was definitely closer to their tribe than the tribe of signature style seekers. 102 | 103 | I learned a lot in the color class I took at RISD, but otherwise I was basically teaching myself to paint, and I could do that for free. So in 1993 I dropped out. I hung around Providence for a bit, and then my college friend Nancy Parmet did me a big favor. A rent-controlled apartment in a building her mother owned in New York was becoming vacant. Did I want it? It wasn't much more than my current place, and New York was supposed to be where the artists were. So yes, I wanted it! [7] 104 | 105 | Asterix comics begin by zooming in on a tiny corner of Roman Gaul that turns out not to be controlled by the Romans. You can do something similar on a map of New York City: if you zoom in on the Upper East Side, there's a tiny corner that's not rich, or at least wasn't in 1993. It's called Yorkville, and that was my new home. Now I was a New York artist — in the strictly technical sense of making paintings and living in New York. 106 | 107 | I was nervous about money, because I could sense that Interleaf was on the way down. Freelance Lisp hacking work was very rare, and I didn't want to have to program in another language, which in those days would have meant C++ if I was lucky. So with my unerring nose for financial opportunity, I decided to write another book on Lisp. This would be a popular book, the sort of book that could be used as a textbook. I imagined myself living frugally off the royalties and spending all my time painting. (The painting on the cover of this book, ANSI Common Lisp, is one that I painted around this time.) 108 | 109 | The best thing about New York for me was the presence of Idelle and Julian Weber. Idelle Weber was a painter, one of the early photorealists, and I'd taken her painting class at Harvard. I've never known a teacher more beloved by her students. Large numbers of former students kept in touch with her, including me. After I moved to New York I became her de facto studio assistant. 110 | 111 | She liked to paint on big, square canvases, 4 to 5 feet on a side. One day in late 1994 as I was stretching one of these monsters there was something on the radio about a famous fund manager. He wasn't that much older than me, and was super rich. The thought suddenly occurred to me: why don't I become rich? Then I'll be able to work on whatever I want. 112 | 113 | Meanwhile I'd been hearing more and more about this new thing called the World Wide Web. Robert Morris showed it to me when I visited him in Cambridge, where he was now in grad school at Harvard. It seemed to me that the web would be a big deal. I'd seen what graphical user interfaces had done for the popularity of microcomputers. It seemed like the web would do the same for the internet. 114 | 115 | If I wanted to get rich, here was the next train leaving the station. I was right about that part. What I got wrong was the idea. I decided we should start a company to put art galleries online. I can't honestly say, after reading so many Y Combinator applications, that this was the worst startup idea ever, but it was up there. Art galleries didn't want to be online, and still don't, not the fancy ones. That's not how they sell. I wrote some software to generate web sites for galleries, and Robert wrote some to resize images and set up an http server to serve the pages. Then we tried to sign up galleries. To call this a difficult sale would be an understatement. It was difficult to give away. A few galleries let us make sites for them for free, but none paid us. 116 | 117 | Then some online stores started to appear, and I realized that except for the order buttons they were identical to the sites we'd been generating for galleries. This impressive-sounding thing called an "internet storefront" was something we already knew how to build. 118 | 119 | So in the summer of 1995, after I submitted the camera-ready copy of ANSI Common Lisp to the publishers, we started trying to write software to build online stores. At first this was going to be normal desktop software, which in those days meant Windows software. That was an alarming prospect, because neither of us knew how to write Windows software or wanted to learn. We lived in the Unix world. But we decided we'd at least try writing a prototype store builder on Unix. Robert wrote a shopping cart, and I wrote a new site generator for stores — in Lisp, of course. 120 | 121 | We were working out of Robert's apartment in Cambridge. His roommate was away for big chunks of time, during which I got to sleep in his room. For some reason there was no bed frame or sheets, just a mattress on the floor. One morning as I was lying on this mattress I had an idea that made me sit up like a capital L. What if we ran the software on the server, and let users control it by clicking on links? Then we'd never have to write anything to run on users' computers. We could generate the sites on the same server we'd serve them from. Users wouldn't need anything more than a browser. 122 | 123 | This kind of software, known as a web app, is common now, but at the time it wasn't clear that it was even possible. To find out, we decided to try making a version of our store builder that you could control through the browser. A couple days later, on August 12, we had one that worked. The UI was horrible, but it proved you could build a whole store through the browser, without any client software or typing anything into the command line on the server. 124 | 125 | Now we felt like we were really onto something. I had visions of a whole new generation of software working this way. You wouldn't need versions, or ports, or any of that crap. At Interleaf there had been a whole group called Release Engineering that seemed to be at least as big as the group that actually wrote the software. Now you could just update the software right on the server. 126 | 127 | We started a new company we called Viaweb, after the fact that our software worked via the web, and we got $10,000 in seed funding from Idelle's husband Julian. In return for that and doing the initial legal work and giving us business advice, we gave him 10% of the company. Ten years later this deal became the model for Y Combinator's. We knew founders needed something like this, because we'd needed it ourselves. 128 | 129 | At this stage I had a negative net worth, because the thousand dollars or so I had in the bank was more than counterbalanced by what I owed the government in taxes. (Had I diligently set aside the proper proportion of the money I'd made consulting for Interleaf? No, I had not.) So although Robert had his graduate student stipend, I needed that seed funding to live on. 130 | 131 | We originally hoped to launch in September, but we got more ambitious about the software as we worked on it. Eventually we managed to build a WYSIWYG site builder, in the sense that as you were creating pages, they looked exactly like the static ones that would be generated later, except that instead of leading to static pages, the links all referred to closures stored in a hash table on the server. 132 | 133 | It helped to have studied art, because the main goal of an online store builder is to make users look legit, and the key to looking legit is high production values. If you get page layouts and fonts and colors right, you can make a guy running a store out of his bedroom look more legit than a big company. 134 | 135 | (If you're curious why my site looks so old-fashioned, it's because it's still made with this software. It may look clunky today, but in 1996 it was the last word in slick.) 136 | 137 | In September, Robert rebelled. "We've been working on this for a month," he said, "and it's still not done." This is funny in retrospect, because he would still be working on it almost 3 years later. But I decided it might be prudent to recruit more programmers, and I asked Robert who else in grad school with him was really good. He recommended Trevor Blackwell, which surprised me at first, because at that point I knew Trevor mainly for his plan to reduce everything in his life to a stack of notecards, which he carried around with him. But Rtm was right, as usual. Trevor turned out to be a frighteningly effective hacker. 138 | 139 | It was a lot of fun working with Robert and Trevor. They're the two most independent-minded people I know, and in completely different ways. If you could see inside Rtm's brain it would look like a colonial New England church, and if you could see inside Trevor's it would look like the worst excesses of Austrian Rococo. 140 | 141 | We opened for business, with 6 stores, in January 1996. It was just as well we waited a few months, because although we worried we were late, we were actually almost fatally early. There was a lot of talk in the press then about ecommerce, but not many people actually wanted online stores. [8] 142 | 143 | There were three main parts to the software: the editor, which people used to build sites and which I wrote, the shopping cart, which Robert wrote, and the manager, which kept track of orders and statistics, and which Trevor wrote. In its time, the editor was one of the best general-purpose site builders. I kept the code tight and didn't have to integrate with any other software except Robert's and Trevor's, so it was quite fun to work on. If all I'd had to do was work on this software, the next 3 years would have been the easiest of my life. Unfortunately I had to do a lot more, all of it stuff I was worse at than programming, and the next 3 years were instead the most stressful. 144 | 145 | There were a lot of startups making ecommerce software in the second half of the 90s. We were determined to be the Microsoft Word, not the Interleaf. Which meant being easy to use and inexpensive. It was lucky for us that we were poor, because that caused us to make Viaweb even more inexpensive than we realized. We charged $100 a month for a small store and $300 a month for a big one. This low price was a big attraction, and a constant thorn in the sides of competitors, but it wasn't because of some clever insight that we set the price low. We had no idea what businesses paid for things. $300 a month seemed like a lot of money to us. 146 | 147 | We did a lot of things right by accident like that. For example, we did what's now called "doing things that don't scale," although at the time we would have described it as "being so lame that we're driven to the most desperate measures to get users." The most common of which was building stores for them. This seemed particularly humiliating, since the whole raison d'etre of our software was that people could use it to make their own stores. But anything to get users. 148 | 149 | We learned a lot more about retail than we wanted to know. For example, that if you could only have a small image of a man's shirt (and all images were small then by present standards), it was better to have a closeup of the collar than a picture of the whole shirt. The reason I remember learning this was that it meant I had to rescan about 30 images of men's shirts. My first set of scans were so beautiful too. 150 | 151 | Though this felt wrong, it was exactly the right thing to be doing. Building stores for users taught us about retail, and about how it felt to use our software. I was initially both mystified and repelled by "business" and thought we needed a "business person" to be in charge of it, but once we started to get users, I was converted, in much the same way I was converted to fatherhood once I had kids. Whatever users wanted, I was all theirs. Maybe one day we'd have so many users that I couldn't scan their images for them, but in the meantime there was nothing more important to do. 152 | 153 | Another thing I didn't get at the time is that growth rate is the ultimate test of a startup. Our growth rate was fine. We had about 70 stores at the end of 1996 and about 500 at the end of 1997. I mistakenly thought the thing that mattered was the absolute number of users. And that is the thing that matters in the sense that that's how much money you're making, and if you're not making enough, you might go out of business. But in the long term the growth rate takes care of the absolute number. If we'd been a startup I was advising at Y Combinator, I would have said: Stop being so stressed out, because you're doing fine. You're growing 7x a year. Just don't hire too many more people and you'll soon be profitable, and then you'll control your own destiny. 154 | 155 | Alas I hired lots more people, partly because our investors wanted me to, and partly because that's what startups did during the Internet Bubble. A company with just a handful of employees would have seemed amateurish. So we didn't reach breakeven until about when Yahoo bought us in the summer of 1998. Which in turn meant we were at the mercy of investors for the entire life of the company. And since both we and our investors were noobs at startups, the result was a mess even by startup standards. 156 | 157 | It was a huge relief when Yahoo bought us. In principle our Viaweb stock was valuable. It was a share in a business that was profitable and growing rapidly. But it didn't feel very valuable to me; I had no idea how to value a business, but I was all too keenly aware of the near-death experiences we seemed to have every few months. Nor had I changed my grad student lifestyle significantly since we started. So when Yahoo bought us it felt like going from rags to riches. Since we were going to California, I bought a car, a yellow 1998 VW GTI. I remember thinking that its leather seats alone were by far the most luxurious thing I owned. 158 | 159 | The next year, from the summer of 1998 to the summer of 1999, must have been the least productive of my life. I didn't realize it at the time, but I was worn out from the effort and stress of running Viaweb. For a while after I got to California I tried to continue my usual m.o. of programming till 3 in the morning, but fatigue combined with Yahoo's prematurely aged culture and grim cube farm in Santa Clara gradually dragged me down. After a few months it felt disconcertingly like working at Interleaf. 160 | 161 | Yahoo had given us a lot of options when they bought us. At the time I thought Yahoo was so overvalued that they'd never be worth anything, but to my astonishment the stock went up 5x in the next year. I hung on till the first chunk of options vested, then in the summer of 1999 I left. It had been so long since I'd painted anything that I'd half forgotten why I was doing this. My brain had been entirely full of software and men's shirts for 4 years. But I had done this to get rich so I could paint, I reminded myself, and now I was rich, so I should go paint. 162 | 163 | When I said I was leaving, my boss at Yahoo had a long conversation with me about my plans. I told him all about the kinds of pictures I wanted to paint. At the time I was touched that he took such an interest in me. Now I realize it was because he thought I was lying. My options at that point were worth about $2 million a month. If I was leaving that kind of money on the table, it could only be to go and start some new startup, and if I did, I might take people with me. This was the height of the Internet Bubble, and Yahoo was ground zero of it. My boss was at that moment a billionaire. Leaving then to start a new startup must have seemed to him an insanely, and yet also plausibly, ambitious plan. 164 | 165 | But I really was quitting to paint, and I started immediately. There was no time to lose. I'd already burned 4 years getting rich. Now when I talk to founders who are leaving after selling their companies, my advice is always the same: take a vacation. That's what I should have done, just gone off somewhere and done nothing for a month or two, but the idea never occurred to me. 166 | 167 | So I tried to paint, but I just didn't seem to have any energy or ambition. Part of the problem was that I didn't know many people in California. I'd compounded this problem by buying a house up in the Santa Cruz Mountains, with a beautiful view but miles from anywhere. I stuck it out for a few more months, then in desperation I went back to New York, where unless you understand about rent control you'll be surprised to hear I still had my apartment, sealed up like a tomb of my old life. Idelle was in New York at least, and there were other people trying to paint there, even though I didn't know any of them. 168 | 169 | When I got back to New York I resumed my old life, except now I was rich. It was as weird as it sounds. I resumed all my old patterns, except now there were doors where there hadn't been. Now when I was tired of walking, all I had to do was raise my hand, and (unless it was raining) a taxi would stop to pick me up. Now when I walked past charming little restaurants I could go in and order lunch. It was exciting for a while. Painting started to go better. I experimented with a new kind of still life where I'd paint one painting in the old way, then photograph it and print it, blown up, on canvas, and then use that as the underpainting for a second still life, painted from the same objects (which hopefully hadn't rotted yet). 170 | 171 | Meanwhile I looked for an apartment to buy. Now I could actually choose what neighborhood to live in. Where, I asked myself and various real estate agents, is the Cambridge of New York? Aided by occasional visits to actual Cambridge, I gradually realized there wasn't one. Huh. 172 | 173 | Around this time, in the spring of 2000, I had an idea. It was clear from our experience with Viaweb that web apps were the future. Why not build a web app for making web apps? Why not let people edit code on our server through the browser, and then host the resulting applications for them? [9] You could run all sorts of services on the servers that these applications could use just by making an API call: making and receiving phone calls, manipulating images, taking credit card payments, etc. 174 | 175 | I got so excited about this idea that I couldn't think about anything else. It seemed obvious that this was the future. I didn't particularly want to start another company, but it was clear that this idea would have to be embodied as one, so I decided to move to Cambridge and start it. I hoped to lure Robert into working on it with me, but there I ran into a hitch. Robert was now a postdoc at MIT, and though he'd made a lot of money the last time I'd lured him into working on one of my schemes, it had also been a huge time sink. So while he agreed that it sounded like a plausible idea, he firmly refused to work on it. 176 | 177 | Hmph. Well, I'd do it myself then. I recruited Dan Giffin, who had worked for Viaweb, and two undergrads who wanted summer jobs, and we got to work trying to build what it's now clear is about twenty companies and several open source projects worth of software. The language for defining applications would of course be a dialect of Lisp. But I wasn't so naive as to assume I could spring an overt Lisp on a general audience; we'd hide the parentheses, like Dylan did. 178 | 179 | By then there was a name for the kind of company Viaweb was, an "application service provider," or ASP. This name didn't last long before it was replaced by "software as a service," but it was current for long enough that I named this new company after it: it was going to be called Aspra. 180 | 181 | I started working on the application builder, Dan worked on network infrastructure, and the two undergrads worked on the first two services (images and phone calls). But about halfway through the summer I realized I really didn't want to run a company — especially not a big one, which it was looking like this would have to be. I'd only started Viaweb because I needed the money. Now that I didn't need money anymore, why was I doing this? If this vision had to be realized as a company, then screw the vision. I'd build a subset that could be done as an open source project. 182 | 183 | Much to my surprise, the time I spent working on this stuff was not wasted after all. After we started Y Combinator, I would often encounter startups working on parts of this new architecture, and it was very useful to have spent so much time thinking about it and even trying to write some of it. 184 | 185 | The subset I would build as an open source project was the new Lisp, whose parentheses I now wouldn't even have to hide. A lot of Lisp hackers dream of building a new Lisp, partly because one of the distinctive features of the language is that it has dialects, and partly, I think, because we have in our minds a Platonic form of Lisp that all existing dialects fall short of. I certainly did. So at the end of the summer Dan and I switched to working on this new dialect of Lisp, which I called Arc, in a house I bought in Cambridge. 186 | 187 | The following spring, lightning struck. I was invited to give a talk at a Lisp conference, so I gave one about how we'd used Lisp at Viaweb. Afterward I put a postscript file of this talk online, on paulgraham.com, which I'd created years before using Viaweb but had never used for anything. In one day it got 30,000 page views. What on earth had happened? The referring urls showed that someone had posted it on Slashdot. [10] 188 | 189 | Wow, I thought, there's an audience. If I write something and put it on the web, anyone can read it. That may seem obvious now, but it was surprising then. In the print era there was a narrow channel to readers, guarded by fierce monsters known as editors. The only way to get an audience for anything you wrote was to get it published as a book, or in a newspaper or magazine. Now anyone could publish anything. 190 | 191 | This had been possible in principle since 1993, but not many people had realized it yet. I had been intimately involved with building the infrastructure of the web for most of that time, and a writer as well, and it had taken me 8 years to realize it. Even then it took me several years to understand the implications. It meant there would be a whole new generation of essays. [11] 192 | 193 | In the print era, the channel for publishing essays had been vanishingly small. Except for a few officially anointed thinkers who went to the right parties in New York, the only people allowed to publish essays were specialists writing about their specialties. There were so many essays that had never been written, because there had been no way to publish them. Now they could be, and I was going to write them. [12] 194 | 195 | I've worked on several different things, but to the extent there was a turning point where I figured out what to work on, it was when I started publishing essays online. From then on I knew that whatever else I did, I'd always write essays too. 196 | 197 | I knew that online essays would be a marginal medium at first. Socially they'd seem more like rants posted by nutjobs on their GeoCities sites than the genteel and beautifully typeset compositions published in The New Yorker. But by this point I knew enough to find that encouraging instead of discouraging. 198 | 199 | One of the most conspicuous patterns I've noticed in my life is how well it has worked, for me at least, to work on things that weren't prestigious. Still life has always been the least prestigious form of painting. Viaweb and Y Combinator both seemed lame when we started them. I still get the glassy eye from strangers when they ask what I'm writing, and I explain that it's an essay I'm going to publish on my web site. Even Lisp, though prestigious intellectually in something like the way Latin is, also seems about as hip. 200 | 201 | It's not that unprestigious types of work are good per se. But when you find yourself drawn to some kind of work despite its current lack of prestige, it's a sign both that there's something real to be discovered there, and that you have the right kind of motives. Impure motives are a big danger for the ambitious. If anything is going to lead you astray, it will be the desire to impress people. So while working on things that aren't prestigious doesn't guarantee you're on the right track, it at least guarantees you're not on the most common type of wrong one. 202 | 203 | Over the next several years I wrote lots of essays about all kinds of different topics. O'Reilly reprinted a collection of them as a book, called Hackers & Painters after one of the essays in it. I also worked on spam filters, and did some more painting. I used to have dinners for a group of friends every thursday night, which taught me how to cook for groups. And I bought another building in Cambridge, a former candy factory (and later, twas said, porn studio), to use as an office. 204 | 205 | One night in October 2003 there was a big party at my house. It was a clever idea of my friend Maria Daniels, who was one of the thursday diners. Three separate hosts would all invite their friends to one party. So for every guest, two thirds of the other guests would be people they didn't know but would probably like. One of the guests was someone I didn't know but would turn out to like a lot: a woman called Jessica Livingston. A couple days later I asked her out. 206 | 207 | Jessica was in charge of marketing at a Boston investment bank. This bank thought it understood startups, but over the next year, as she met friends of mine from the startup world, she was surprised how different reality was. And how colorful their stories were. So she decided to compile a book of interviews with startup founders. 208 | 209 | When the bank had financial problems and she had to fire half her staff, she started looking for a new job. In early 2005 she interviewed for a marketing job at a Boston VC firm. It took them weeks to make up their minds, and during this time I started telling her about all the things that needed to be fixed about venture capital. They should make a larger number of smaller investments instead of a handful of giant ones, they should be funding younger, more technical founders instead of MBAs, they should let the founders remain as CEO, and so on. 210 | 211 | One of my tricks for writing essays had always been to give talks. The prospect of having to stand up in front of a group of people and tell them something that won't waste their time is a great spur to the imagination. When the Harvard Computer Society, the undergrad computer club, asked me to give a talk, I decided I would tell them how to start a startup. Maybe they'd be able to avoid the worst of the mistakes we'd made. 212 | 213 | So I gave this talk, in the course of which I told them that the best sources of seed funding were successful startup founders, because then they'd be sources of advice too. Whereupon it seemed they were all looking expectantly at me. Horrified at the prospect of having my inbox flooded by business plans (if I'd only known), I blurted out "But not me!" and went on with the talk. But afterward it occurred to me that I should really stop procrastinating about angel investing. I'd been meaning to since Yahoo bought us, and now it was 7 years later and I still hadn't done one angel investment. 214 | 215 | Meanwhile I had been scheming with Robert and Trevor about projects we could work on together. I missed working with them, and it seemed like there had to be something we could collaborate on. 216 | 217 | As Jessica and I were walking home from dinner on March 11, at the corner of Garden and Walker streets, these three threads converged. Screw the VCs who were taking so long to make up their minds. We'd start our own investment firm and actually implement the ideas we'd been talking about. I'd fund it, and Jessica could quit her job and work for it, and we'd get Robert and Trevor as partners too. [13] 218 | 219 | Once again, ignorance worked in our favor. We had no idea how to be angel investors, and in Boston in 2005 there were no Ron Conways to learn from. So we just made what seemed like the obvious choices, and some of the things we did turned out to be novel. 220 | 221 | There are multiple components to Y Combinator, and we didn't figure them all out at once. The part we got first was to be an angel firm. In those days, those two words didn't go together. There were VC firms, which were organized companies with people whose job it was to make investments, but they only did big, million dollar investments. And there were angels, who did smaller investments, but these were individuals who were usually focused on other things and made investments on the side. And neither of them helped founders enough in the beginning. We knew how helpless founders were in some respects, because we remembered how helpless we'd been. For example, one thing Julian had done for us that seemed to us like magic was to get us set up as a company. We were fine writing fairly difficult software, but actually getting incorporated, with bylaws and stock and all that stuff, how on earth did you do that? Our plan was not only to make seed investments, but to do for startups everything Julian had done for us. 222 | 223 | YC was not organized as a fund. It was cheap enough to run that we funded it with our own money. That went right by 99% of readers, but professional investors are thinking "Wow, that means they got all the returns." But once again, this was not due to any particular insight on our part. We didn't know how VC firms were organized. It never occurred to us to try to raise a fund, and if it had, we wouldn't have known where to start. [14] 224 | 225 | The most distinctive thing about YC is the batch model: to fund a bunch of startups all at once, twice a year, and then to spend three months focusing intensively on trying to help them. That part we discovered by accident, not merely implicitly but explicitly due to our ignorance about investing. We needed to get experience as investors. What better way, we thought, than to fund a whole bunch of startups at once? We knew undergrads got temporary jobs at tech companies during the summer. Why not organize a summer program where they'd start startups instead? We wouldn't feel guilty for being in a sense fake investors, because they would in a similar sense be fake founders. So while we probably wouldn't make much money out of it, we'd at least get to practice being investors on them, and they for their part would probably have a more interesting summer than they would working at Microsoft. 226 | 227 | We'd use the building I owned in Cambridge as our headquarters. We'd all have dinner there once a week — on tuesdays, since I was already cooking for the thursday diners on thursdays — and after dinner we'd bring in experts on startups to give talks. 228 | 229 | We knew undergrads were deciding then about summer jobs, so in a matter of days we cooked up something we called the Summer Founders Program, and I posted an announcement on my site, inviting undergrads to apply. I had never imagined that writing essays would be a way to get "deal flow," as investors call it, but it turned out to be the perfect source. [15] We got 225 applications for the Summer Founders Program, and we were surprised to find that a lot of them were from people who'd already graduated, or were about to that spring. Already this SFP thing was starting to feel more serious than we'd intended. 230 | 231 | We invited about 20 of the 225 groups to interview in person, and from those we picked 8 to fund. They were an impressive group. That first batch included reddit, Justin Kan and Emmett Shear, who went on to found Twitch, Aaron Swartz, who had already helped write the RSS spec and would a few years later become a martyr for open access, and Sam Altman, who would later become the second president of YC. I don't think it was entirely luck that the first batch was so good. You had to be pretty bold to sign up for a weird thing like the Summer Founders Program instead of a summer job at a legit place like Microsoft or Goldman Sachs. 232 | 233 | The deal for startups was based on a combination of the deal we did with Julian ($10k for 10%) and what Robert said MIT grad students got for the summer ($6k). We invested $6k per founder, which in the typical two-founder case was $12k, in return for 6%. That had to be fair, because it was twice as good as the deal we ourselves had taken. Plus that first summer, which was really hot, Jessica brought the founders free air conditioners. [16] 234 | 235 | Fairly quickly I realized that we had stumbled upon the way to scale startup funding. Funding startups in batches was more convenient for us, because it meant we could do things for a lot of startups at once, but being part of a batch was better for the startups too. It solved one of the biggest problems faced by founders: the isolation. Now you not only had colleagues, but colleagues who understood the problems you were facing and could tell you how they were solving them. 236 | 237 | As YC grew, we started to notice other advantages of scale. The alumni became a tight community, dedicated to helping one another, and especially the current batch, whose shoes they remembered being in. We also noticed that the startups were becoming one another's customers. We used to refer jokingly to the "YC GDP," but as YC grows this becomes less and less of a joke. Now lots of startups get their initial set of customers almost entirely from among their batchmates. 238 | 239 | I had not originally intended YC to be a full-time job. I was going to do three things: hack, write essays, and work on YC. As YC grew, and I grew more excited about it, it started to take up a lot more than a third of my attention. But for the first few years I was still able to work on other things. 240 | 241 | In the summer of 2006, Robert and I started working on a new version of Arc. This one was reasonably fast, because it was compiled into Scheme. To test this new Arc, I wrote Hacker News in it. It was originally meant to be a news aggregator for startup founders and was called Startup News, but after a few months I got tired of reading about nothing but startups. Plus it wasn't startup founders we wanted to reach. It was future startup founders. So I changed the name to Hacker News and the topic to whatever engaged one's intellectual curiosity. 242 | 243 | HN was no doubt good for YC, but it was also by far the biggest source of stress for me. If all I'd had to do was select and help founders, life would have been so easy. And that implies that HN was a mistake. Surely the biggest source of stress in one's work should at least be something close to the core of the work. Whereas I was like someone who was in pain while running a marathon not from the exertion of running, but because I had a blister from an ill-fitting shoe. When I was dealing with some urgent problem during YC, there was about a 60% chance it had to do with HN, and a 40% chance it had do with everything else combined. [17] 244 | 245 | As well as HN, I wrote all of YC's internal software in Arc. But while I continued to work a good deal in Arc, I gradually stopped working on Arc, partly because I didn't have time to, and partly because it was a lot less attractive to mess around with the language now that we had all this infrastructure depending on it. So now my three projects were reduced to two: writing essays and working on YC. 246 | 247 | YC was different from other kinds of work I've done. Instead of deciding for myself what to work on, the problems came to me. Every 6 months there was a new batch of startups, and their problems, whatever they were, became our problems. It was very engaging work, because their problems were quite varied, and the good founders were very effective. If you were trying to learn the most you could about startups in the shortest possible time, you couldn't have picked a better way to do it. 248 | 249 | There were parts of the job I didn't like. Disputes between cofounders, figuring out when people were lying to us, fighting with people who maltreated the startups, and so on. But I worked hard even at the parts I didn't like. I was haunted by something Kevin Hale once said about companies: "No one works harder than the boss." He meant it both descriptively and prescriptively, and it was the second part that scared me. I wanted YC to be good, so if how hard I worked set the upper bound on how hard everyone else worked, I'd better work very hard. 250 | 251 | One day in 2010, when he was visiting California for interviews, Robert Morris did something astonishing: he offered me unsolicited advice. I can only remember him doing that once before. One day at Viaweb, when I was bent over double from a kidney stone, he suggested that it would be a good idea for him to take me to the hospital. That was what it took for Rtm to offer unsolicited advice. So I remember his exact words very clearly. "You know," he said, "you should make sure Y Combinator isn't the last cool thing you do." 252 | 253 | At the time I didn't understand what he meant, but gradually it dawned on me that he was saying I should quit. This seemed strange advice, because YC was doing great. But if there was one thing rarer than Rtm offering advice, it was Rtm being wrong. So this set me thinking. It was true that on my current trajectory, YC would be the last thing I did, because it was only taking up more of my attention. It had already eaten Arc, and was in the process of eating essays too. Either YC was my life's work or I'd have to leave eventually. And it wasn't, so I would. 254 | 255 | In the summer of 2012 my mother had a stroke, and the cause turned out to be a blood clot caused by colon cancer. The stroke destroyed her balance, and she was put in a nursing home, but she really wanted to get out of it and back to her house, and my sister and I were determined to help her do it. I used to fly up to Oregon to visit her regularly, and I had a lot of time to think on those flights. On one of them I realized I was ready to hand YC over to someone else. 256 | 257 | I asked Jessica if she wanted to be president, but she didn't, so we decided we'd try to recruit Sam Altman. We talked to Robert and Trevor and we agreed to make it a complete changing of the guard. Up till that point YC had been controlled by the original LLC we four had started. But we wanted YC to last for a long time, and to do that it couldn't be controlled by the founders. So if Sam said yes, we'd let him reorganize YC. Robert and I would retire, and Jessica and Trevor would become ordinary partners. 258 | 259 | When we asked Sam if he wanted to be president of YC, initially he said no. He wanted to start a startup to make nuclear reactors. But I kept at it, and in October 2013 he finally agreed. We decided he'd take over starting with the winter 2014 batch. For the rest of 2013 I left running YC more and more to Sam, partly so he could learn the job, and partly because I was focused on my mother, whose cancer had returned. 260 | 261 | She died on January 15, 2014. We knew this was coming, but it was still hard when it did. 262 | 263 | I kept working on YC till March, to help get that batch of startups through Demo Day, then I checked out pretty completely. (I still talk to alumni and to new startups working on things I'm interested in, but that only takes a few hours a week.) 264 | 265 | What should I do next? Rtm's advice hadn't included anything about that. I wanted to do something completely different, so I decided I'd paint. I wanted to see how good I could get if I really focused on it. So the day after I stopped working on YC, I started painting. I was rusty and it took a while to get back into shape, but it was at least completely engaging. [18] 266 | 267 | I spent most of the rest of 2014 painting. I'd never been able to work so uninterruptedly before, and I got to be better than I had been. Not good enough, but better. Then in November, right in the middle of a painting, I ran out of steam. Up till that point I'd always been curious to see how the painting I was working on would turn out, but suddenly finishing this one seemed like a chore. So I stopped working on it and cleaned my brushes and haven't painted since. So far anyway. 268 | 269 | I realize that sounds rather wimpy. But attention is a zero sum game. If you can choose what to work on, and you choose a project that's not the best one (or at least a good one) for you, then it's getting in the way of another project that is. And at 50 there was some opportunity cost to screwing around. 270 | 271 | I started writing essays again, and wrote a bunch of new ones over the next few months. I even wrote a couple that weren't about startups. Then in March 2015 I started working on Lisp again. 272 | 273 | The distinctive thing about Lisp is that its core is a language defined by writing an interpreter in itself. It wasn't originally intended as a programming language in the ordinary sense. It was meant to be a formal model of computation, an alternative to the Turing machine. If you want to write an interpreter for a language in itself, what's the minimum set of predefined operators you need? The Lisp that John McCarthy invented, or more accurately discovered, is an answer to that question. [19] 274 | 275 | McCarthy didn't realize this Lisp could even be used to program computers till his grad student Steve Russell suggested it. Russell translated McCarthy's interpreter into IBM 704 machine language, and from that point Lisp started also to be a programming language in the ordinary sense. But its origins as a model of computation gave it a power and elegance that other languages couldn't match. It was this that attracted me in college, though I didn't understand why at the time. 276 | 277 | McCarthy's 1960 Lisp did nothing more than interpret Lisp expressions. It was missing a lot of things you'd want in a programming language. So these had to be added, and when they were, they weren't defined using McCarthy's original axiomatic approach. That wouldn't have been feasible at the time. McCarthy tested his interpreter by hand-simulating the execution of programs. But it was already getting close to the limit of interpreters you could test that way — indeed, there was a bug in it that McCarthy had overlooked. To test a more complicated interpreter, you'd have had to run it, and computers then weren't powerful enough. 278 | 279 | Now they are, though. Now you could continue using McCarthy's axiomatic approach till you'd defined a complete programming language. And as long as every change you made to McCarthy's Lisp was a discoveredness-preserving transformation, you could, in principle, end up with a complete language that had this quality. Harder to do than to talk about, of course, but if it was possible in principle, why not try? So I decided to take a shot at it. It took 4 years, from March 26, 2015 to October 12, 2019. It was fortunate that I had a precisely defined goal, or it would have been hard to keep at it for so long. 280 | 281 | I wrote this new Lisp, called Bel, in itself in Arc. That may sound like a contradiction, but it's an indication of the sort of trickery I had to engage in to make this work. By means of an egregious collection of hacks I managed to make something close enough to an interpreter written in itself that could actually run. Not fast, but fast enough to test. 282 | 283 | I had to ban myself from writing essays during most of this time, or I'd never have finished. In late 2015 I spent 3 months writing essays, and when I went back to working on Bel I could barely understand the code. Not so much because it was badly written as because the problem is so convoluted. When you're working on an interpreter written in itself, it's hard to keep track of what's happening at what level, and errors can be practically encrypted by the time you get them. 284 | 285 | So I said no more essays till Bel was done. But I told few people about Bel while I was working on it. So for years it must have seemed that I was doing nothing, when in fact I was working harder than I'd ever worked on anything. Occasionally after wrestling for hours with some gruesome bug I'd check Twitter or HN and see someone asking "Does Paul Graham still code?" 286 | 287 | Working on Bel was hard but satisfying. I worked on it so intensively that at any given time I had a decent chunk of the code in my head and could write more there. I remember taking the boys to the coast on a sunny day in 2015 and figuring out how to deal with some problem involving continuations while I watched them play in the tide pools. It felt like I was doing life right. I remember that because I was slightly dismayed at how novel it felt. The good news is that I had more moments like this over the next few years. 288 | 289 | In the summer of 2016 we moved to England. We wanted our kids to see what it was like living in another country, and since I was a British citizen by birth, that seemed the obvious choice. We only meant to stay for a year, but we liked it so much that we still live there. So most of Bel was written in England. 290 | 291 | In the fall of 2019, Bel was finally finished. Like McCarthy's original Lisp, it's a spec rather than an implementation, although like McCarthy's Lisp it's a spec expressed as code. 292 | 293 | Now that I could write essays again, I wrote a bunch about topics I'd had stacked up. I kept writing essays through 2020, but I also started to think about other things I could work on. How should I choose what to do? Well, how had I chosen what to work on in the past? I wrote an essay for myself to answer that question, and I was surprised how long and messy the answer turned out to be. If this surprised me, who'd lived it, then I thought perhaps it would be interesting to other people, and encouraging to those with similarly messy lives. So I wrote a more detailed version for others to read, and this is the last sentence of it. 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | Notes 304 | 305 | [1] My experience skipped a step in the evolution of computers: time-sharing machines with interactive OSes. I went straight from batch processing to microcomputers, which made microcomputers seem all the more exciting. 306 | 307 | [2] Italian words for abstract concepts can nearly always be predicted from their English cognates (except for occasional traps like polluzione). It's the everyday words that differ. So if you string together a lot of abstract concepts with a few simple verbs, you can make a little Italian go a long way. 308 | 309 | [3] I lived at Piazza San Felice 4, so my walk to the Accademia went straight down the spine of old Florence: past the Pitti, across the bridge, past Orsanmichele, between the Duomo and the Baptistery, and then up Via Ricasoli to Piazza San Marco. I saw Florence at street level in every possible condition, from empty dark winter evenings to sweltering summer days when the streets were packed with tourists. 310 | 311 | [4] You can of course paint people like still lives if you want to, and they're willing. That sort of portrait is arguably the apex of still life painting, though the long sitting does tend to produce pained expressions in the sitters. 312 | 313 | [5] Interleaf was one of many companies that had smart people and built impressive technology, and yet got crushed by Moore's Law. In the 1990s the exponential growth in the power of commodity (i.e. Intel) processors rolled up high-end, special-purpose hardware and software companies like a bulldozer. 314 | 315 | [6] The signature style seekers at RISD weren't specifically mercenary. In the art world, money and coolness are tightly coupled. Anything expensive comes to be seen as cool, and anything seen as cool will soon become equally expensive. 316 | 317 | [7] Technically the apartment wasn't rent-controlled but rent-stabilized, but this is a refinement only New Yorkers would know or care about. The point is that it was really cheap, less than half market price. 318 | 319 | [8] Most software you can launch as soon as it's done. But when the software is an online store builder and you're hosting the stores, if you don't have any users yet, that fact will be painfully obvious. So before we could launch publicly we had to launch privately, in the sense of recruiting an initial set of users and making sure they had decent-looking stores. 320 | 321 | [9] We'd had a code editor in Viaweb for users to define their own page styles. They didn't know it, but they were editing Lisp expressions underneath. But this wasn't an app editor, because the code ran when the merchants' sites were generated, not when shoppers visited them. 322 | 323 | [10] This was the first instance of what is now a familiar experience, and so was what happened next, when I read the comments and found they were full of angry people. How could I claim that Lisp was better than other languages? Weren't they all Turing complete? People who see the responses to essays I write sometimes tell me how sorry they feel for me, but I'm not exaggerating when I reply that it has always been like this, since the very beginning. It comes with the territory. An essay must tell readers things they don't already know, and some people dislike being told such things. 324 | 325 | [11] People put plenty of stuff on the internet in the 90s of course, but putting something online is not the same as publishing it online. Publishing online means you treat the online version as the (or at least a) primary version. 326 | 327 | [12] There is a general lesson here that our experience with Y Combinator also teaches: Customs continue to constrain you long after the restrictions that caused them have disappeared. Customary VC practice had once, like the customs about publishing essays, been based on real constraints. Startups had once been much more expensive to start, and proportionally rare. Now they could be cheap and common, but the VCs' customs still reflected the old world, just as customs about writing essays still reflected the constraints of the print era. 328 | 329 | Which in turn implies that people who are independent-minded (i.e. less influenced by custom) will have an advantage in fields affected by rapid change (where customs are more likely to be obsolete). 330 | 331 | Here's an interesting point, though: you can't always predict which fields will be affected by rapid change. Obviously software and venture capital will be, but who would have predicted that essay writing would be? 332 | 333 | [13] Y Combinator was not the original name. At first we were called Cambridge Seed. But we didn't want a regional name, in case someone copied us in Silicon Valley, so we renamed ourselves after one of the coolest tricks in the lambda calculus, the Y combinator. 334 | 335 | I picked orange as our color partly because it's the warmest, and partly because no VC used it. In 2005 all the VCs used staid colors like maroon, navy blue, and forest green, because they were trying to appeal to LPs, not founders. The YC logo itself is an inside joke: the Viaweb logo had been a white V on a red circle, so I made the YC logo a white Y on an orange square. 336 | 337 | [14] YC did become a fund for a couple years starting in 2009, because it was getting so big I could no longer afford to fund it personally. But after Heroku got bought we had enough money to go back to being self-funded. 338 | 339 | [15] I've never liked the term "deal flow," because it implies that the number of new startups at any given time is fixed. This is not only false, but it's the purpose of YC to falsify it, by causing startups to be founded that would not otherwise have existed. 340 | 341 | [16] She reports that they were all different shapes and sizes, because there was a run on air conditioners and she had to get whatever she could, but that they were all heavier than she could carry now. 342 | 343 | [17] Another problem with HN was a bizarre edge case that occurs when you both write essays and run a forum. When you run a forum, you're assumed to see if not every conversation, at least every conversation involving you. And when you write essays, people post highly imaginative misinterpretations of them on forums. Individually these two phenomena are tedious but bearable, but the combination is disastrous. You actually have to respond to the misinterpretations, because the assumption that you're present in the conversation means that not responding to any sufficiently upvoted misinterpretation reads as a tacit admission that it's correct. But that in turn encourages more; anyone who wants to pick a fight with you senses that now is their chance. 344 | 345 | [18] The worst thing about leaving YC was not working with Jessica anymore. We'd been working on YC almost the whole time we'd known each other, and we'd neither tried nor wanted to separate it from our personal lives, so leaving was like pulling up a deeply rooted tree. 346 | 347 | [19] One way to get more precise about the concept of invented vs discovered is to talk about space aliens. Any sufficiently advanced alien civilization would certainly know about the Pythagorean theorem, for example. I believe, though with less certainty, that they would also know about the Lisp in McCarthy's 1960 paper. 348 | 349 | But if so there's no reason to suppose that this is the limit of the language that might be known to them. Presumably aliens need numbers and errors and I/O too. So it seems likely there exists at least one path out of McCarthy's Lisp along which discoveredness is preserved. 350 | 351 | 352 | 353 | Thanks to Trevor Blackwell, John Collison, Patrick Collison, Daniel Gackle, Ralph Hazell, Jessica Livingston, Robert Morris, and Harj Taggar for reading drafts of this. 354 | -------------------------------------------------------------------------------- /llama_poe/data/queries.md: -------------------------------------------------------------------------------- 1 | # Queries over your Data 2 | 3 | At a high level, LlamaIndex gives you the ability to query your data for any downstream 4 | LLM use case, whether it's question-answering, summarization, or a component in a 5 | chatbot. 6 | 7 | This section describes the different ways you can query your data with LlamaIndex, 8 | roughly in order of simplest (top-k semantic search), to more advanced capabilities. 9 | 10 | ### Semantic Search 11 | 12 | The most basic example usage of LlamaIndex is through semantic search. We provide a 13 | simple in-memory vector store for you to get started, but you can also choose to use any 14 | one of our [vector store integrations](/how_to/integrations/vector_stores.md): 15 | 16 | ```python 17 | from llama_index import GPTSimpleVectorIndex, SimpleDirectoryReader 18 | documents = SimpleDirectoryReader('data').load_data() 19 | index = GPTSimpleVectorIndex.from_documents(documents) 20 | response = index.query("What did the author do growing up?") 21 | print(response) 22 | 23 | ``` 24 | 25 | Relevant Resources: 26 | 27 | - [Quickstart](/getting_started/starter_example.md) 28 | - [Example notebook](https://github.com/jerryjliu/llama_index/tree/main/examples/vector_indices) 29 | 30 | ### Summarization 31 | 32 | A summarization query requires the LLM to iterate through many if not most documents in 33 | order to synthesize an answer. For instance, a summarization query could look like one 34 | of the following: 35 | 36 | - "What is a summary of this collection of text?" 37 | - "Give me a summary of person X's experience with the company." 38 | 39 | In general, a list index would be suited for this use case. A list index by default goes 40 | through all the data. 41 | 42 | Empirically, setting `response_mode="tree_summarize"` also leads to better summarization 43 | results. 44 | 45 | ```python 46 | index = GPTListIndex.from_documents(documents) 47 | 48 | response = index.query("", response_mode="tree_summarize") 49 | ``` 50 | 51 | ### Queries over Structured Data 52 | 53 | LlamaIndex supports queries over structured data, whether that's a Pandas DataFrame or a 54 | SQL Database. 55 | 56 | Here are some relevant resources: 57 | 58 | - [Guide on Text-to-SQL](/guides/tutorials/sql_guide.md) 59 | - [SQL Demo Notebook 1](https://github.com/jerryjliu/llama_index/blob/main/examples/struct_indices/SQLIndexDemo.ipynb) 60 | - [SQL Demo Notebook 2 (Context)](https://github.com/jerryjliu/llama_index/blob/main/examples/struct_indices/SQLIndexDemo-Context.ipynb) 61 | - [SQL Demo Notebook 3 (Big tables)](https://github.com/jerryjliu/llama_index/blob/main/examples/struct_indices/SQLIndexDemo-ManyTables.ipynb) 62 | - [Pandas Demo Notebook](https://github.com/jerryjliu/llama_index/blob/main/examples/struct_indices/PandasIndexDemo.ipynb). 63 | 64 | ### Synthesis over Heterogenous Data 65 | 66 | LlamaIndex supports synthesizing across heterogenous data sources. This can be done by 67 | composing a graph over your existing data. Specifically, compose a list index over your 68 | subindices. A list index inherently combines information for each node; therefore it can 69 | synthesize information across your heteregenous data sources. 70 | 71 | ```python 72 | from llama_index import GPTSimpleVectorIndex, GPTListIndex 73 | from llama_index.indices.composability import ComposableGraph 74 | 75 | index1 = GPTSimpleVectorIndex.from_documents(notion_docs) 76 | index2 = GPTSimpleVectorIndex.from_documents(slack_docs) 77 | 78 | graph = ComposableGraph.from_indices(GPTListIndex, [index1, index2], index_summaries=["summary1", "summary2"]) 79 | response = graph.query("", mode="recursive", query_configs=...) 80 | 81 | ``` 82 | 83 | Here are some relevant resources: 84 | 85 | - [Composability](/how_to/index_structs/composability.md) 86 | - [City Analysis Demo](https://github.com/jerryjliu/llama_index/blob/main/examples/composable_indices/city_analysis/PineconeDemo-CityAnalysis.ipynb). 87 | 88 | ### Routing over Heterogenous Data 89 | 90 | LlamaIndex also supports routing over heteregenous data sources - for instance, if you 91 | want to "route" a query to an underlying Document or a subindex. Here you have three 92 | options: `GPTTreeIndex`, `GPTKeywordTableIndex`, or a 93 | [Vector Store Index](vector-store-index). 94 | 95 | A `GPTTreeIndex` uses the LLM to select the child node(s) to send the query down to. A 96 | `GPTKeywordTableIndex` uses keyword matching, and a `GPTVectorStoreIndex` uses embedding 97 | cosine similarity. 98 | 99 | ```python 100 | from llama_index import GPTTreeIndex, GPTSimpleVectorIndex 101 | from llama_index.indices.composability import ComposableGraph 102 | 103 | ... 104 | 105 | # subindices 106 | index1 = GPTSimpleVectorIndex.from_documents(notion_docs) 107 | index2 = GPTSimpleVectorIndex.from_documents(slack_docs) 108 | 109 | # tree index for routing 110 | tree_index = ComposableGraph.from_indices( 111 | GPTTreeIndex, 112 | [index1, index2], 113 | index_summaries=["summary1", "summary2"] 114 | ) 115 | 116 | response = tree_index.query( 117 | "In Notion, give me a summary of the product roadmap.", 118 | mode="recursive", 119 | query_configs=... 120 | ) 121 | 122 | ``` 123 | 124 | Here are some relevant resources: 125 | 126 | - [Composability](/how_to/index_structs/composability.md) 127 | - [Composable Keyword Table Graph](https://github.com/jerryjliu/llama_index/blob/main/examples/composable_indices/ComposableIndices.ipynb). 128 | 129 | ### Compare/Contrast Queries 130 | 131 | LlamaIndex can support compare/contrast queries as well. It can do this in the following 132 | fashion: 133 | 134 | - Composing a graph over your data 135 | - Adding in query transformations. 136 | 137 | You can perform compare/contrast queries by just composing a graph over your data. 138 | 139 | Here are some relevant resources: 140 | 141 | - [Composability](/how_to/index_structs/composability.md) 142 | - [SEC 10-k Analysis Example notebook](https://colab.research.google.com/drive/1uL1TdMbR4kqa0Ksrd_Of_jWSxWt1ia7o?usp=sharing). 143 | 144 | You can also perform compare/contrast queries with a **query transformation** module. 145 | 146 | ```python 147 | from gpt_index.indices.query.query_transform.base import DecomposeQueryTransform 148 | decompose_transform = DecomposeQueryTransform( 149 | llm_predictor_chatgpt, verbose=True 150 | ) 151 | ``` 152 | 153 | This module will help break down a complex query into a simpler one over your existing 154 | index structure. 155 | 156 | Here are some relevant resources: 157 | 158 | - [Query Transformations](/how_to/query/query_transformations.md) 159 | - [City Analysis Example Notebook](https://github.com/jerryjliu/llama_index/blob/main/examples/composable_indices/city_analysis/City_Analysis-Decompose.ipynb) 160 | 161 | ### Multi-Step Queries 162 | 163 | LlamaIndex can also support multi-step queries. Given a complex query, break it down 164 | into subquestions. 165 | 166 | For instance, given a question "Who was in the first batch of the accelerator program 167 | the author started?", the module will first decompose the query into a simpler initial 168 | question "What was the accelerator program the author started?", query the index, and 169 | then ask followup questions. 170 | 171 | Here are some relevant resources: 172 | 173 | - [Query Transformations](/how_to/query/query_transformations.md) 174 | - [Multi-Step Query Decomposition Notebook](https://github.com/jerryjliu/llama_index/blob/main/examples/vector_indices/SimpleIndexDemo-multistep.ipynb) 175 | -------------------------------------------------------------------------------- /llama_poe/index.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/llama_poe/index.json -------------------------------------------------------------------------------- /llama_poe/poe_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/llama_poe/poe_api/__init__.py -------------------------------------------------------------------------------- /llama_poe/poe_api/llama_handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | LlamaIndex Bot. 3 | """ 4 | from __future__ import annotations 5 | 6 | import logging 7 | import os 8 | from typing import AsyncIterable, Sequence 9 | 10 | from fastapi.responses import JSONResponse 11 | from langchain import LLMChain, OpenAI 12 | from langchain.chains.conversational_retrieval.prompts import CONDENSE_QUESTION_PROMPT 13 | from llama_index import Document as LlamaDocument, IndexStructType 14 | from llama_index.indices.base import BaseGPTIndex 15 | from llama_index.indices.registry import INDEX_STRUCT_TYPE_TO_INDEX_CLASS 16 | from llama_index.readers import SimpleDirectoryReader 17 | from poe_api.types import AddDocumentsRequest, Document 18 | from sse_starlette.sse import ServerSentEvent 19 | 20 | from fastapi_poe.base import PoeBot 21 | from fastapi_poe.types import ( 22 | QueryRequest, 23 | ReportFeedbackRequest, 24 | SettingsRequest, 25 | SettingsResponse, 26 | ) 27 | 28 | LOAD_DATA = os.environ.get("LLAMA_LOAD_DATA", True) 29 | DATA_DIR = os.environ.get("LLAMA_DATA_DIR", "data/") 30 | 31 | INDEX_STRUCT_TYPE_STR = os.environ.get( 32 | "LLAMA_INDEX_TYPE", IndexStructType.SIMPLE_DICT.value 33 | ) 34 | INDEX_JSON_PATH = os.environ.get("LLAMA_INDEX_JSON_PATH", "save/index.json") 35 | 36 | EXTERNAL_VECTOR_STORE_INDEX_STRUCT_TYPES = [ 37 | IndexStructType.DICT, 38 | IndexStructType.WEAVIATE, 39 | IndexStructType.PINECONE, 40 | IndexStructType.QDRANT, 41 | IndexStructType.CHROMA, 42 | IndexStructType.VECTOR_STORE, 43 | ] 44 | 45 | SETTINGS = SettingsResponse( 46 | context_clear_window_secs=60 * 60, allow_user_context_clear=True 47 | ) 48 | 49 | logger = logging.getLogger(__name__) 50 | 51 | 52 | def _to_llama_documents(docs: Sequence[Document]) -> list[LlamaDocument]: 53 | return [LlamaDocument(text=doc.text, doc_id=doc.doc_id) for doc in docs] 54 | 55 | 56 | def _create_or_load_index( 57 | index_type_str: str | None = None, 58 | index_json_path: str | None = None, 59 | index_type_to_index_cls: dict[str, type[BaseGPTIndex]] | None = None, 60 | ) -> BaseGPTIndex: 61 | """Create or load index from json path.""" 62 | index_json_path = index_json_path or INDEX_JSON_PATH 63 | index_type_to_index_cls = ( 64 | index_type_to_index_cls or INDEX_STRUCT_TYPE_TO_INDEX_CLASS 65 | ) 66 | index_type_str = index_type_str or INDEX_STRUCT_TYPE_STR 67 | index_type = IndexStructType(index_type_str) 68 | 69 | if index_type not in index_type_to_index_cls: 70 | raise ValueError(f"Unknown index type: {index_type}") 71 | 72 | # TODO: support external vector store 73 | if index_type in EXTERNAL_VECTOR_STORE_INDEX_STRUCT_TYPES: 74 | raise ValueError("Please use vector store directly.") 75 | 76 | index_cls = index_type_to_index_cls[index_type] 77 | try: 78 | # Load index from disk 79 | index = index_cls.load_from_disk(index_json_path) 80 | logger.info(f"Loading index from {index_json_path}") 81 | return index 82 | except OSError: 83 | # Create empty index 84 | index = index_cls(nodes=[]) 85 | logger.info("Creating new index") 86 | 87 | if LOAD_DATA: 88 | logger.info(f"Loading data from {DATA_DIR}") 89 | reader = SimpleDirectoryReader(input_dir=DATA_DIR) 90 | documents = reader.load_data() 91 | nodes = index.service_context.node_parser.get_nodes_from_documents( 92 | documents 93 | ) 94 | index.insert_nodes(nodes) 95 | 96 | return index 97 | 98 | 99 | def _get_chat_history(chat_history: list[tuple[str, str]]) -> str: 100 | buffer = "" 101 | for human_s, ai_s in chat_history: 102 | human = "Human: " + human_s 103 | ai = "Assistant: " + ai_s 104 | buffer += "\n" + "\n".join([human, ai]) 105 | return buffer 106 | 107 | 108 | class LlamaBot(PoeBot): 109 | def __init__(self) -> None: 110 | """Setup LlamaIndex.""" 111 | self._chat_history = {} 112 | self._index = _create_or_load_index() 113 | 114 | async def get_response(self, query: QueryRequest) -> AsyncIterable[ServerSentEvent]: 115 | """Return an async iterator of events to send to the user.""" 116 | # Get chat history 117 | chat_history = self._chat_history.get(query.conversation_id) 118 | if chat_history is None: 119 | chat_history = [] 120 | self._chat_history[query.conversation_id] = chat_history 121 | 122 | # Get last message 123 | last_message = query.query[-1].content 124 | 125 | # Generate standalone question from conversation context and last message 126 | question_gen_model = OpenAI(temperature=0) 127 | question_generator = LLMChain( 128 | llm=question_gen_model, prompt=CONDENSE_QUESTION_PROMPT 129 | ) 130 | 131 | chat_history_str = _get_chat_history(chat_history) 132 | logger.debug(chat_history_str) 133 | new_question = question_generator.run( 134 | question=last_message, chat_history=chat_history_str 135 | ) 136 | logger.info(f"Querying with: {new_question}") 137 | 138 | # Query with standalone question 139 | response = await self._index.aquery( 140 | new_question, streaming=True, similarity_top_k=3 141 | ) 142 | full_response = "" 143 | for text in response.response_gen: 144 | full_response += text 145 | yield self.text_event(text) 146 | 147 | chat_history.append((last_message, full_response)) 148 | 149 | async def on_feedback(self, feedback: ReportFeedbackRequest) -> None: 150 | """Called when we receive user feedback such as likes.""" 151 | logger.info( 152 | f"User {feedback.user_id} gave feedback on {feedback.conversation_id}" 153 | f"message {feedback.message_id}: {feedback.feedback_type}" 154 | ) 155 | 156 | async def get_settings(self, settings: SettingsRequest) -> SettingsResponse: 157 | """Return the settings for this bot.""" 158 | return SETTINGS 159 | 160 | async def add_documents(self, request: AddDocumentsRequest) -> None: 161 | """Add documents.""" 162 | llama_docs = _to_llama_documents(request.documents) 163 | nodes = self._index.service_context.node_parser.get_nodes_from_documents( 164 | llama_docs 165 | ) 166 | self._index.insert_nodes(nodes) 167 | 168 | async def handle_add_documents(self, request: AddDocumentsRequest) -> JSONResponse: 169 | await self.add_documents(request) 170 | return JSONResponse({}) 171 | 172 | def handle_shutdown(self) -> None: 173 | """Save index upon shutdown.""" 174 | self._index.save_to_disk(INDEX_JSON_PATH) 175 | -------------------------------------------------------------------------------- /llama_poe/poe_api/server.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import os 4 | from typing import Any, Dict 5 | 6 | import uvicorn.config 7 | from fastapi import Depends, FastAPI, HTTPException, Request, Response 8 | from fastapi.exceptions import RequestValidationError 9 | from fastapi.responses import HTMLResponse 10 | from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 11 | from poe_api import llama_handler 12 | from poe_api.types import AddDocumentsRequest 13 | from poe_api.utils import LoggingMiddleware 14 | from sse_starlette.sse import EventSourceResponse 15 | 16 | from fastapi_poe.types import ( 17 | QueryRequest, 18 | ReportErrorRequest, 19 | ReportFeedbackRequest, 20 | SettingsRequest, 21 | ) 22 | 23 | global logger 24 | logger = logging.getLogger("uvicorn.default") 25 | 26 | http_bearer = HTTPBearer() 27 | BEARER_TOKEN = os.environ.get("POE_API_KEY") 28 | assert BEARER_TOKEN is not None 29 | 30 | 31 | def exception_handler(request: Request, ex: HTTPException): 32 | del request 33 | logger.error(ex) 34 | 35 | 36 | def auth_user( 37 | authorization: HTTPAuthorizationCredentials = Depends(http_bearer), 38 | ) -> None: 39 | if authorization.scheme != "Bearer" or authorization.credentials != BEARER_TOKEN: 40 | raise HTTPException( 41 | status_code=401, 42 | detail="Invalid API key", 43 | headers={"WWW-Authenticate": "Bearer"}, 44 | ) 45 | 46 | 47 | app = FastAPI() 48 | app.add_exception_handler(RequestValidationError, exception_handler) 49 | 50 | # Uncomment this line to print out request and response 51 | app.add_middleware(LoggingMiddleware) 52 | logger.info("Starting") 53 | 54 | log_config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG) 55 | log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" 56 | 57 | 58 | @app.get("/") 59 | async def index() -> Response: 60 | url = "https://poe.com/create_bot?api=1" 61 | return HTMLResponse( 62 | "

LlamaIndex Poe bot server

Congratulations! Your server" 63 | " is running. To connect it to Poe, create a bot at {url}.

' 65 | ) 66 | 67 | 68 | @app.post("/") 69 | async def poe_post(request: Dict[str, Any], dict=Depends(auth_user)) -> Response: 70 | if request["type"] == "query": 71 | return EventSourceResponse( 72 | handler.handle_query(QueryRequest.parse_obj(request)) 73 | ) 74 | elif request["type"] == "settings": 75 | return await handler.handle_settings(SettingsRequest.parse_obj(request)) 76 | elif request["type"] == "report_feedback": 77 | return await handler.handle_report_feedback( 78 | ReportFeedbackRequest.parse_obj(request) 79 | ) 80 | elif request["type"] == "report_error": 81 | return await handler.handle_report_error(ReportErrorRequest.parse_obj(request)) 82 | else: 83 | raise HTTPException(status_code=501, detail="Unsupported request type") 84 | 85 | 86 | @app.post("/add_document") 87 | async def add_document( 88 | request_dict: Dict[str, Any], dict=Depends(auth_user) 89 | ) -> Response: 90 | request = AddDocumentsRequest.parse_obj(request_dict) 91 | return await handler.handle_add_documents(request) 92 | 93 | 94 | @app.on_event("startup") 95 | async def startup(): 96 | global handler 97 | handler = llama_handler.LlamaBot() 98 | 99 | 100 | @app.on_event("shutdown") 101 | def shutdown(): 102 | handler.handle_shutdown() 103 | 104 | 105 | def start(): 106 | """ 107 | Run a Poe bot server using FastAPI. 108 | 109 | :param handler: The bot handler. 110 | :param api_key: The Poe API key to use. If not provided, it will try to read 111 | the POE_API_KEY environment. If that is not set, the server will not require 112 | authentication. 113 | 114 | """ 115 | uvicorn.run( 116 | "poe_api.server:app", 117 | host="0.0.0.0", 118 | port=8080, 119 | log_config=log_config, 120 | reload=True, 121 | ) 122 | -------------------------------------------------------------------------------- /llama_poe/poe_api/types.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Document(BaseModel): 7 | doc_id: str 8 | text: str 9 | 10 | 11 | class AddDocumentsRequest(BaseModel): 12 | """Request parameters for an add_documents request.""" 13 | 14 | documents: List[Document] 15 | -------------------------------------------------------------------------------- /llama_poe/poe_api/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from fastapi import Request 5 | from starlette.middleware.base import BaseHTTPMiddleware 6 | 7 | logger = logging.getLogger("uvicorn.default") 8 | 9 | 10 | class LoggingMiddleware(BaseHTTPMiddleware): 11 | async def set_body(self, request: Request): 12 | receive_ = await request._receive() 13 | 14 | async def receive(): 15 | return receive_ 16 | 17 | request._receive = receive 18 | 19 | async def dispatch(self, request: Request, call_next): 20 | logger.info(f"Request: {request.method} {request.url}") 21 | try: 22 | # Per https://github.com/tiangolo/fastapi/issues/394#issuecomment-927272627 23 | # to avoid blocking. 24 | await self.set_body(request) 25 | body = await request.json() 26 | logger.debug(f"Request body: {json.dumps(body)}") 27 | except json.JSONDecodeError: 28 | logger.error("Request body: Unable to parse JSON") 29 | 30 | response = await call_next(request) 31 | 32 | logger.info(f"Response status: {response.status_code}") 33 | try: 34 | if hasattr(response, "body"): 35 | body = json.loads(response.body.decode()) 36 | logger.debug(f"Response body: {json.dumps(body)}") 37 | except json.JSONDecodeError: 38 | logger.error("Response body: Unable to parse JSON") 39 | 40 | return response 41 | -------------------------------------------------------------------------------- /llama_poe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "llama_poe" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["LlamaIndex Inc."] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "poe_api"}] 9 | 10 | [tool.poetry.scripts] 11 | start = "poe_api.server:start" 12 | 13 | [tool.poetry.dependencies] 14 | python = ">=3.8.1,<4.0" 15 | llama_index = "^0.5.15" 16 | openai = "^0.27.3" 17 | black = "^23.3.0" 18 | isort = "^5.12.0" 19 | fastapi = "^0.95.1" 20 | sse-starlette = "^1.3.3" 21 | typing-extensions = "^4.5.0" 22 | uvicorn = "^0.21.1" 23 | fastapi-poe = "^0.0.7" 24 | 25 | 26 | [build-system] 27 | requires = ["poetry-core"] 28 | build-backend = "poetry.core.masonry.api" 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py37'] 3 | skip-magic-trailing-comma = true 4 | 5 | [tool.pycln] 6 | all = true 7 | 8 | [tool.isort] 9 | profile = "black" 10 | combine_as_imports = true 11 | skip_gitignore = true 12 | -------------------------------------------------------------------------------- /simulator_poe/README.md: -------------------------------------------------------------------------------- 1 | # Poe Server Simulator 2 | 3 | A Poe Server simulator has been developed to simplify the process of locally testing 4 | your bot server. Please note that this simulator is intended for testing purposes only, 5 | and there may be variations from the actual Poe server. Therefore, it is still crucial 6 | to thoroughly test your bot server before its release. 7 | 8 | ## Getting Started 9 | 10 | To use the Poe server simulator, follow these steps: 11 | 12 | - Clone this repository and navigate to the directory. 13 | - Install the server by running the command `pip install .`. 14 | - Start the simulator by running `python3 -m simulator_poe` 15 | - By default, the simulator communicates with a bot server that listens on 16 | `127.0.0.1:8080`. If you want to use a different bot server, run the command 17 | `BOT_SERVER= python3 -m simulator_poe` 18 | - You can now begin chatting with your bot server using the simulator! 19 | 20 | ![alt text](poe_server.png "Title") 21 | 22 | ## Limitations 23 | 24 | - The Poe server only processes query messages 25 | - It always prints the text as plain text 26 | - The user ID and conversation ID remain fixed. 27 | -------------------------------------------------------------------------------- /simulator_poe/poe_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poe-platform/poe-protocol/2d4dd30dbf1a2b706be00992ef2a8a4075718a89/simulator_poe/poe_server.png -------------------------------------------------------------------------------- /simulator_poe/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "simulator_poe" 7 | version = "0.0.1" 8 | authors = [ 9 | { name="Lida Li", email="lli@quora.com" }, 10 | ] 11 | description = "A simulator to test Poe bot " 12 | readme = "README.md" 13 | requires-python = ">=3.7" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "aiohttp-sse-client2", 21 | "fastapi", 22 | "prompt_toolkit", 23 | "pydantic", 24 | "typing-extensions", 25 | ] 26 | 27 | [project.urls] 28 | "Homepage" = "https://github.com/quora/poe-protocol" 29 | 30 | [tool.pyright] 31 | pythonVersion = "3.7" 32 | -------------------------------------------------------------------------------- /simulator_poe/src/simulator_poe/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["PoeServer", "AsyncBotClient", "ServerContext"] 2 | 3 | from .async_bot_client import AsyncBotClient 4 | from .poe_server import PoeServer, ServerContext 5 | -------------------------------------------------------------------------------- /simulator_poe/src/simulator_poe/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from simulator_poe import PoeServer 4 | 5 | if __name__ == "__main__": 6 | bot_server = os.environ.get("BOT_SERVER", "127.0.0.1:8080") 7 | server = PoeServer(bot_server) 8 | server.start() 9 | -------------------------------------------------------------------------------- /simulator_poe/src/simulator_poe/async_bot_client.py: -------------------------------------------------------------------------------- 1 | import os # noqa: F401 2 | import time 3 | 4 | from aiohttp import ClientSession 5 | from aiohttp_sse_client2 import client 6 | from fastapi.encoders import jsonable_encoder 7 | 8 | from simulator_poe.poe_messages import ProtocolMessage, QueryRequest 9 | 10 | _USER_ID = 0 11 | _API_VERSION = "1.0" 12 | 13 | 14 | class AsyncBotClient: 15 | def __init__(self, end_point): 16 | self.end_point = end_point 17 | self.session = None 18 | self.headers = {"Authorization": f"bearer {os.environ.get('POE_API_KEY')}"} 19 | self.conversation_id = "c-1234567" 20 | self.msg_id = 0 21 | 22 | def build_single_Message(self, role, msg): 23 | ret = ProtocolMessage( 24 | role=role, 25 | content=msg, 26 | content_type="text/plain", 27 | timestamp=round(time.time() * 1000000), 28 | message_id=f"m-{self.msg_id}", 29 | message_type=None, 30 | feedback=[], 31 | ) 32 | self.msg_id += 1 33 | return ret 34 | 35 | def build_query_Message(self, msg, context): 36 | protocol_msg = self.build_single_Message("user", msg) 37 | context.messages.append(protocol_msg) 38 | 39 | ret = QueryRequest( 40 | version=_API_VERSION, 41 | type="query", 42 | user_id=_USER_ID, 43 | conversation_id=self.conversation_id, 44 | message_id=f"m-{self.msg_id}", 45 | query=context.messages, 46 | ) 47 | return jsonable_encoder(ret) 48 | 49 | def on_error(self): 50 | print("Error streaming events") 51 | raise RuntimeError("Error streaming events") 52 | 53 | async def stream_request(self, msg, context, debug=False): 54 | if self.session is None: 55 | self.session = ClientSession() 56 | body = self.build_query_Message(msg, context) 57 | if debug: 58 | print(f"Sending to the bot server: {body}") 59 | async with client.EventSource( 60 | f"http://{self.end_point}", 61 | option={"method": "POST"}, 62 | session=self.session, 63 | headers=self.headers, 64 | json=body, 65 | on_error=self.on_error, 66 | ) as event_source: 67 | try: 68 | async for event in event_source: 69 | yield event 70 | except ConnectionError: 71 | print("Connection error") 72 | -------------------------------------------------------------------------------- /simulator_poe/src/simulator_poe/poe_messages.py: -------------------------------------------------------------------------------- 1 | # TODO: Merge with fastapi_poe 2 | 3 | from typing import Any, Dict, List, Optional 4 | 5 | from pydantic import BaseModel 6 | from typing_extensions import Literal, TypeAlias 7 | 8 | Identifier: TypeAlias = str 9 | FeedbackType: TypeAlias = Literal["like", "dislike"] 10 | ContentType: TypeAlias = Literal["text/markdown", "text/plain"] 11 | 12 | 13 | class MessageFeedback(BaseModel): 14 | """Feedback for a message as used in the Poe protocol.""" 15 | 16 | type: FeedbackType 17 | reason: Optional[str] 18 | 19 | 20 | class ProtocolMessage(BaseModel): 21 | """A message as used in the Poe protocol.""" 22 | 23 | role: Literal["system", "user", "bot", "assistant"] 24 | content: str 25 | content_type: ContentType 26 | timestamp: int 27 | message_id: str 28 | message_type: Optional[str] 29 | feedback: List[MessageFeedback] 30 | 31 | 32 | class BaseRequest(BaseModel): 33 | """Common data for all requests.""" 34 | 35 | version: str 36 | type: Literal["query", "settings", "report_feedback", "report_error"] 37 | 38 | 39 | class QueryRequest(BaseRequest): 40 | """Request parameters for a query request.""" 41 | 42 | query: List[ProtocolMessage] 43 | user_id: Identifier 44 | conversation_id: Identifier 45 | message_id: Identifier 46 | 47 | 48 | class SettingsRequest(BaseRequest): 49 | """Request parameters for a settings request.""" 50 | 51 | 52 | class ReportFeedbackRequest(BaseRequest): 53 | """Request parameters for a report_feedback request.""" 54 | 55 | message_id: Identifier 56 | user_id: Identifier 57 | conversation_id: Identifier 58 | feedback_type: FeedbackType 59 | 60 | 61 | class ReportErrorRequest(BaseRequest): 62 | """Request parameters for a report_error request.""" 63 | 64 | message: str 65 | metadata: Dict[str, Any] 66 | 67 | 68 | class SettingsResponse(BaseModel): 69 | context_clear_window_secs: Optional[int] = None 70 | allow_user_context_clear: bool = True 71 | -------------------------------------------------------------------------------- /simulator_poe/src/simulator_poe/poe_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import json 4 | from typing import List 5 | 6 | from prompt_toolkit import HTML, print_formatted_text, prompt 7 | from prompt_toolkit.styles import Style 8 | 9 | from simulator_poe.async_bot_client import AsyncBotClient 10 | from simulator_poe.poe_messages import ProtocolMessage 11 | 12 | 13 | @dataclasses.dataclass 14 | class ServerContext: 15 | """The context of the server. This is the state of the conversation.""" 16 | 17 | messages: List[ProtocolMessage] 18 | 19 | 20 | style = Style.from_dict( 21 | {"poe": "#5d5cde", "bot": "#af875f", "info": "#008000", "error": "#ff0000"} 22 | ) 23 | 24 | 25 | class PoeServer: 26 | """The Poe server simulator. This is the server that the bot connects to.""" 27 | 28 | def __init__(self, bot_server): 29 | self.context = ServerContext(messages=[]) 30 | self.bot_client = AsyncBotClient(bot_server) 31 | self.debug = False 32 | 33 | def print_usage(self): 34 | print_formatted_text(HTML("Welcome to the Poe server simulator!")) 35 | print_formatted_text(HTML("!q -- quit Poe server simulator")) 36 | print_formatted_text(HTML("!c -- clear the context")) 37 | print_formatted_text(HTML("!d -- toggle debug mode")) 38 | 39 | def start(self): 40 | self.print_usage() 41 | 42 | while True: 43 | answer = prompt(HTML("Poe server > "), style=style) 44 | if answer == "!q": 45 | if self.bot_client and self.bot_client.session: 46 | asyncio.get_event_loop().run_until_complete( 47 | self.bot_client.session.close() 48 | ) 49 | return 50 | elif answer == "!c": 51 | self.context.messages = [] 52 | print_formatted_text(HTML("Context cleared"), style=style) 53 | continue 54 | elif answer == "!d": 55 | self.debug = not self.debug 56 | print_formatted_text( 57 | HTML(f"Debug set to {self.debug}"), style=style 58 | ) 59 | continue 60 | print_formatted_text() 61 | asyncio.get_event_loop().run_until_complete(self.send_message(answer)) 62 | print_formatted_text() 63 | 64 | async def send_message(self, msg: str): 65 | print_formatted_text(HTML("Bot server > "), end="", style=style) 66 | content = "" 67 | async for event in self.bot_client.stream_request( 68 | msg, self.context, debug=self.debug 69 | ): 70 | if event.message == "text": 71 | text_data = json.loads(event.data) 72 | content += text_data["text"] 73 | print(text_data["text"]) 74 | elif event.message == "done": 75 | self.context.messages.append( 76 | self.bot_client.build_single_Message("bot", content) 77 | ) 78 | return 79 | -------------------------------------------------------------------------------- /spec.md: -------------------------------------------------------------------------------- 1 | # Poe protocol 2 | 3 | This is a draft specification. It is still subject to change. 4 | 5 | ## Introduction 6 | 7 | This specification provides a way to run custom bots from any web-accessible service 8 | that can be used by the Poe app. Poe users will send requests to the Poe server, which 9 | will in turn send requests to the bot server using this specification. As the bot server 10 | responds, Poe will show the response to the user. 11 | 12 | This specification consists of three sections: 13 | 14 | - A “Terminology” section defining common terms 15 | - A “Common topics” section outlining some concepts used throughout the specification 16 | - A “Requests” section defining the request types that are part of the spec. 17 | 18 | ## Terminology 19 | 20 | - _Poe server:_ server run by Poe that receives client requests, turns them into 21 | requests to bot servers, and streams the response back to the Poe client. 22 | - _Bot server:_ server that provides an LLM response. Poe servers make a request to the 23 | bot servers and receive an LLM response. 24 | 25 | ## Common topics 26 | 27 | ### Versioning 28 | 29 | It is expected that the API will be extended in the future to support additional 30 | features. The protocol version string consists of two numbers (e.g., 1.0). 31 | 32 | The first number is the _request version_. It will be incremented if the form the 33 | request takes changes in an incompatible fashion. For example, the current protocol only 34 | contains a single API call from Poe servers to bot servers. If we were to add another 35 | API call that bot servers are expected to support, we would increment the request 36 | version (e.g., from 1.0 to 2.0). This is expected to be very rare, and it will be 37 | communicated to bot servers well in advance. 38 | 39 | The second number is the _response version._ It will be incremented whenever the Poe 40 | server adds support for a new feature that bot servers can use. For example, as of this 41 | version we support two content types in LLM responses. If we were to add a third, we 42 | would increment the response version (e.g., from 1.0 to 1.1). Bot servers written for an 43 | earlier response version will continue to work; they simply will not use the new 44 | feature. 45 | 46 | The response version is also incremented for backward-compatible changes to the request. 47 | For example, if we add a new field to the request body, we would increment the response 48 | version. This is safe for old bot servers as they will simply ignore the added field. 49 | 50 | Throughout the protocol, bot servers should ignore any dictionary keys without a 51 | specified meaning. They may be used in a future version of the protocol. 52 | 53 | ### Identifiers 54 | 55 | The protocol uses identifiers for certain request fields. These are labeled as 56 | “identifier” in the specification. Identifiers are globally unique. They consist of a 57 | sequence of 1 to 3 lowercase ASCII characters, followed by a hyphen, followed by 32 58 | lowercase alphanumeric ASCII characters (i.e., they fulfill the regex 59 | `^[a-z]{1,3}-[a-z0-9]{32}$`). 60 | 61 | The characters before the hyphen are a tag that represents the type of the object. The 62 | following types are currently in use: 63 | 64 | - `m`: represents a message 65 | - `u`: represents a user 66 | - `c`: represents a conversation 67 | 68 | ### Authentication 69 | 70 | When a user creates a bot, we assign a randomly generated token consisting of 32 ASCII 71 | characters. To confirm that requests come from Poe servers, all requests will have an 72 | Authorization HTTP header “Bearer \”, where \ is the token. Bot servers 73 | can use this to validate that requests come from real Poe servers. 74 | 75 | ### Context window 76 | 77 | When we send a query request to the bot server, we send the previous messages in this 78 | conversation, both from the user and from the bot. However, in some cases we truncate 79 | the conservation. This is called the _context window_. 80 | 81 | By default, there are two ways for the context window to reset: 82 | 83 | - When the user clicks the icon in the app that resets context. 84 | - When the user has not interacted with the bot for some time (around 1 hour, but the 85 | exact cutoff is subject to change). 86 | 87 | The `settings` endpoint (see below) allows bot servers to control these two modes of 88 | context clearing through the `allow_user_context_clear` and `context_clear_window_secs` 89 | settings. 90 | 91 | The context window can grow arbitrarily large if the bot’s settings disallow context 92 | clearing, or simply if the user never manually clears their context and continually 93 | talks to the bot. 94 | 95 | ### Content types 96 | 97 | Messages may use the following content types: 98 | 99 | - `text/plain`: Plain text, rendered without further processing 100 | - `text/markdown`: Markdown text. Specifically, this supports all features of 101 | GitHub-Flavored Markdown (GFM, specified at https://github.github.com/gfm/). Poe may 102 | however modify the rendered Markdown for security or usability reasons. In particular, 103 | images are not yet supported. 104 | 105 | ### Limits 106 | 107 | Poe may implement limits on bot servers to ensure the reliability and scalability of the 108 | product. In particular: 109 | 110 | - The initial response to any request must be returned within 5 seconds. 111 | - The response to any request (including `query` requests) must be completed within 120 112 | seconds. 113 | - The total length of a bot response (the sum of the length of all `text` events sent in 114 | response to a `query` request) may not exceed 10,000 characters. 115 | - The total number of events sent in response to a `query` event may not exceed 1000. 116 | 117 | We may raise these limits in the future if good use cases come up. 118 | 119 | ## Requests 120 | 121 | The Poe server will send an HTTP POST request to the bot servers URL with content type 122 | `application/json`. The body will be a JSON dictionary with the following keys: 123 | 124 | - `version` (string): The API version that the server is using. 125 | - `type` (string): This is one of the following strings: 126 | - `query`: Called when the user makes a query to the bot (i.e., they send a message). 127 | - `settings`: Query the bot for its desired settings. 128 | - `report_feedback`: Report to the bot server when certain events happen (e.g., the 129 | user likes a message). 130 | - `report_error`: Report to the bot server when an error happens that is attributable 131 | to the bot (e.g., it uses the protocol incorrectly). 132 | - Additional request types may be added in the future. Bot servers should ignore any 133 | request types they do not understand, ideally by sending a `501 Not Implemented` 134 | HTTP response. 135 | 136 | Each of the request types is discussed in detail below. 137 | 138 | ### The `query` request type 139 | 140 | In addition to the request fields that are valid for all queries, `query` requests take 141 | the following parameters in the request: 142 | 143 | - `query`: An array containing one or more dictionaries that represent a previous 144 | message in the conversation with the bot. These are in chronological order, the most 145 | recent message last. It includes all messages in the current context window (see 146 | above). These dictionaries contain the following keys: 147 | - `role` (string): one of the following strings: 148 | - `system`: A message that tells the bot how it should work. Example: “You are an AI 149 | assistant that gives useful information to Poe users.” 150 | - `user`: A message from the user. Example: “What is the capital of Nepal?" 151 | - `bot`: A response from the bot. Example: “The capital of Nepal is Kathmandu.” 152 | - More roles may be added in the future. Bot servers should ignore messages with 153 | roles they do not recognize. 154 | - `content` (string): The text of the message. 155 | - `content_type` (string): The content type of the message (see under “Content type” 156 | above). Bots should ignore messages with content types they do not understand. 157 | - `timestamp` (int): The time the message was sent, as the number of microseconds 158 | since the Unix epoch. 159 | - `message_id` (identifier with type m): Identifier for this message. 160 | - `feedback` (array): A list of dictionaries representing feedback that the user gave 161 | to the message. Each dictionary has the following keys: 162 | - `type` (string): Either `like` or `dislike`. More types may be added in the future 163 | and bot servers should ignore types they do not recognize. 164 | - `reason` (string): A string representing the reason for the action. This key may 165 | be omitted. 166 | - `message_id` (identifier with type m): identifier for the message that the bot will 167 | create; also used for the `report_feedback` endpoint 168 | - `user_id` (identifier with type `u`): the user making the request 169 | - `conversation_id` (identifier with type `c`): identifier for the conversation the user 170 | is currently having. Resets when context is cleared. 171 | 172 | The bot server should respond with an HTTP response code of 200. If any other response 173 | code is returned, the Poe server will show an error message to the user. The server must 174 | respond with a stream of server-sent events, as specified by the WhatWG 175 | (https://html.spec.whatwg.org/multipage/server-sent-events.html). 176 | 177 | Server-sent events contain a type and data. The Poe API supports several event types 178 | with different meanings. For each type, the data is a JSON string as specified below. 179 | The following event types are supported: 180 | 181 | - `meta`: represents metadata about how the Poe server should treat the bot server 182 | response. This event should be the first event sent back by the bot server. If no 183 | `meta` event is given, the default values are used. If a `meta` event is not the first 184 | event, the behavior is unspecified; currently it is ignored but future extensions to 185 | the protocol may allow multiple `meta` events in one response. The data dictionary may 186 | have the following keys: 187 | - `content_type` (string, defaults to `text/markdown`): If this is `text/markdown`, 188 | the response is rendered as Markdown by the Poe client. If it is `text/plain`, the 189 | response is rendered as plain text. Other values are unsupported and are treated 190 | like `text/plain`. 191 | - `linkify` (boolean, defaults to false): If this is true, Poe will automatically add 192 | links to the response that generate additional queries to the bot server. 193 | - `suggested_replies` (boolean, defaults to false): If this is true, Poe will suggest 194 | followup messages to the user that they might want to send to the bot. If this is 195 | false, no suggested replies will be shown to the user. Note that the protocol also 196 | supports bots sending their own suggested replies (see below). If the bot server 197 | sends any `suggested_reply` event, Poe will not show any of its own suggested 198 | replies, only those suggested by the bot, regardless of the value of the 199 | `suggested_replies` setting. 200 | - `refetch_settings` (boolean, defaults to false): Setting this to true advises the 201 | Poe server that it should refetch the `settings` endpoint and update the settings 202 | for this bot. Bot servers should set this to true when they wish to change their 203 | settings. The Poe server may not refetch settings for every response with this field 204 | set; for example, it may refetch only once per hour or day. 205 | - `text`: represents a piece of text to send to the user. This is a partial response; 206 | the text shown to the user when the request is complete will be a concatenation of the 207 | texts from all `text` events. The data dictionary may have the following keys: 208 | - `text` (string): A partial response to the user’s query 209 | - `replace_response`: like `text`, but discards all previous `text` events. The user 210 | will no longer see the previous responses, and instead see only the text provided by 211 | this event. The data dictionary must have the following keys: 212 | - `text` (string): A partial response to the user's query 213 | - `suggested_reply`: represents a suggested followup query that the user can send to 214 | reply to the bot’s query. The Poe UI may show these followups as buttons the user can 215 | press to immediately send a new query. The data dictionary has the following keys: 216 | - `text` (string): Text of the suggested reply. 217 | - `error`: indicates that an error occurred in the bot server. If this event type is 218 | received, the server will close the connection and indicate to the user that there was 219 | an error communicating with the bot server. The server may retry the request. The data 220 | dictionary may contain the following keys: 221 | - `allow_retry` (boolean): If this is False, the server will not retry the request. If 222 | this is True or omitted, the server may retry the request. 223 | - `text` (string): A message indicating more details about the error. This message 224 | will not be shown to the user, but Poe will use it for internal diagnostic purposes. 225 | May be omitted. 226 | - `done`: must be the last event in the stream, indicating that the bot response is 227 | finished. The server will close the connection after this event is received. The data 228 | for this event is currently ignored, but it must be valid JSON. 229 | 230 | The bot response must include at least one `text` or `error` event; it is an error to 231 | send no response. 232 | 233 | If the Poe server receives an event type it does not recognize, it ignores the event. 234 | 235 | ### The `settings` request type 236 | 237 | This request takes no additional request parameters other than the standard ones. 238 | 239 | The server should respond with a response code of 200 and content type of 240 | `application/json`. The JSON response should be a dictionary containing the keys listed 241 | below. All keys are optional; if they are not specified the default values are used. Poe 242 | reserves the right to change the defaults at any time, so if bots rely on a particular 243 | setting, they should set it explicitly. 244 | 245 | If a settings request fails (it does not return a 200 response code with a valid JSON 246 | body), the previous settings are used for the bot. If this is the first request, that 247 | means the default values are used for all settings; if it is a refetch request, the 248 | settings previously used for the bot remain in use. If the request does not return a 2xx 249 | or 501 response code, the Poe server may retry the settings request after some time. 250 | 251 | The response may contain the following keys: 252 | 253 | - `context_clear_window_secs` (integer or null): If the user does not talk to the bot 254 | for this many seconds, we clear the context and start a fresh conversation. Future 255 | `query` requests will use a new conversation identifier and will not include previous 256 | messages in the prompt. For example, suppose this parameter is set to 1800 (30 \* 60), 257 | and a user sends a message to a bot at 1 pm. If they then send another message at 1:20 258 | pm, the query send to the bot server will include the previous message and response, 259 | but if they send a third message at 2 pm, the query will include only the new message. 260 | If this parameter is set to 0, the context window is never cleared automatically, 261 | regardless of how long it has been since the user talked to the bot. If the parameter 262 | is omitted or `null`, the value will be determined by the Poe server. 263 | - `allow_user_context_clear` (boolean): If true, allow users to directly clear their 264 | context window, meaning that their conversation with this bot will reset to a clean 265 | slate. This is independent from whether Poe itself will automatically clear context 266 | after a certain time (as controlled by `context_clear_window_secs`). The default is 267 | true. 268 | 269 | ### The `report_feedback` request type 270 | 271 | This request takes the following additional parameters: 272 | 273 | - `message_id` (identifier with type m): The message for which feedback is provided. 274 | - `user_id` (identifier with type `u`): The user providing the feedback 275 | - `conversation_id` (identifier with type `c`): The conversation giving rise to the 276 | feedback 277 | - `feedback_type` (string): May be `like` or `dislike`. Additional types may be added in 278 | the future; bot servers should ignore feedback with types they do not recognize. 279 | 280 | The server’s response is ignored. 281 | 282 | ### The `report_error` request type 283 | 284 | When the bot server fails to use the protocol correctly (e.g., when it uses the wrong 285 | types in response to a `settings` request, the Poe server may make a `report_error` 286 | request to the server that reports what went wrong. The protocol does not guarantee that 287 | the endpoint will be called for every error; it is merely intended as a convenience to 288 | help bot server developers debug their bot. 289 | 290 | This request takes the following additional parameters: 291 | 292 | - `message` (string): A string describing the error. 293 | - `metadata` (dictionary): May contain metadata that may be helpful in diagnosing the 294 | error, such as the conversation_id for the conversation. The exact contents of the 295 | dictionary are not specified and may change at any time. 296 | 297 | The server’s response is ignored. 298 | 299 | ## Example 300 | 301 | ![Screenshot of a Poe chat where the user asks "What is the capital of Nepal?" and the bot answers "The capital of Nepal is Kathmandu".](./images/chat-sample.png) 302 | 303 | Suppose we’re having the above conversation over Poe with a bot server running at 304 | `https://ai.example.com/llm`. 305 | 306 | For the Poe conversation above, the Poe server sends a POST request to 307 | `https://ai.example.com/llm` with the following JSON in the body: 308 | 309 | ``` 310 | { 311 | "version": "1.0", 312 | "type": "query", 313 | "query": [ 314 | { 315 | "role": "user", 316 | "content": "What is the capital of Nepal?", 317 | "content_type": "text/markdown", 318 | "timestamp": 1678299819427621, 319 | } 320 | ], 321 | "user": "u-1234abcd5678efgh", 322 | "conversation": "c-jklm9012nopq3456", 323 | } 324 | ``` 325 | 326 | The provider responds with an HTTP 200 response code, then sends the following 327 | server-sent events: 328 | 329 | ``` 330 | event: meta 331 | data: {"content_type": "text/markdown", "linkify": true} 332 | 333 | event: text 334 | data: {"text": "The"} 335 | 336 | event: text 337 | data: {"text": " capital of Nepal is"} 338 | 339 | event: text 340 | data: {"text": " Kathmandu."} 341 | 342 | event: done 343 | data: {} 344 | ``` 345 | 346 | The text may also be split into more or fewer individual events as desired. Sending more 347 | events means that users will see partial responses from the bot server faster. 348 | --------------------------------------------------------------------------------